From 8940ef25c32a4f908ed52bee65793797589db101 Mon Sep 17 00:00:00 2001 From: Florian Schmidt Date: Sun, 20 Sep 2026 10:29:13 +0200 Subject: [PATCH] Analysis: one selected period, one set of numbers, on every page The dashboards told several stories at once. Overview asked for full calendar years, meter detail for a fixed 12-month window that was really 13, Trends for 24 months with an Apply button, and the energy pages for 60. Each page derived "today" from UTC, so the first hours of a local day belonged to yesterday. A missing tariff, a month nobody measured and a genuine zero all rendered as 0. And a virtual meter -- the one thing the spreadsheet leans on hardest -- was excluded from analysis outright: MeterPeriodService returned null for it and the page offered a flow diagram instead. docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58 plus amendments A-01..A-30; code, tests and release notes cite those ids. The analysis layer Core/Analysis holds the pure rules: period presets resolved once in the instance zone into a local date range and a half-open UTC range, bucket plans, calendar-unit comparisons, coverage runs with a resolution class, normalized quantities and units, the totals policy, the virtual formula parser/validator/evaluator, and the cost calculator. "Now" comes from TimeProvider; services never read the clock. Normalization now writes, in the same transaction as consumption and by diff, per-meter rollups by local day and month plus coverage runs and a rollup state (AnalysisDataWriter). AnalysisReader answers a request from those tables -- month rollups for month and year buckets, day rollups otherwise, at most two partial edge days from consumption -- and CostReader prices the result month by month. Pages, /api/v1 and the CSV export read nothing else. The unused continuous aggregates are dropped. The reader's statement count per request is constant whether it covers one meter or a thousand. On a synthetic 1,000-meter, ten-year instance the brief's target request (100 meters, ten years, monthly) takes 374 ms against a two-second target, and the Overview went from 48,244 SQL statements per load to 205. Missing is not zero Every bucket carries a status -- available, partial, missing, unresolved, invalid, pending -- derived from coverage, never from the amount, with provenance and a reason code beside it. A true zero is a number and a bar on the baseline; an unknown bucket is a gap that says why; a month whose data only exists monthly says so instead of inventing daily detail; a scope with no tariff says "not priced" instead of 0. Rows whose interval closes after now are reported separately rather than counted. Virtual meters are analysis subjects A virtual meter stores a canonical definition -- expression over m references, result kind, unit and cost rule -- validated on save and on read for syntax, unknown or self references, loops and unit/kind rules. It is evaluated on read from its sources' rollups over their joint coverage: a missing source makes the bucket missing, an observed zero is a valid input, a non-finite result is invalid with its dependency path, and the page lists each source's contribution. Topology links are topology only and never rewrite a saved calculation; expression-less meters from older installs are converted once at startup. The editor has Sum, Difference and Advanced modes with a live preview. Totals and the bill Per energy type the totals policy separates use, grid import, export, generation and runtime, marks breakdown meters as breakdowns and virtual meters as views, and never adds across units. The bill follows it: grid import where there is one, separately priced subsections at their own price, feed-in only on export meters, standing charges once per scope per local day, manual costs once on their start day, categories as non-overlapping covers whose composition reconciles to the bill. The seeded demo's yearly totals now match the spreadsheet. Pages and navigation The period lives in the URL and every page reads the same contract, so a link, a reload and the browser's Back button keep it. Shared components carry it: page header with breadcrumbs, period toolbar, theme-aware chart with an accessible table beside it, metric cards, comparison and availability states, attention items that each link to the one action that fixes them. Meter detail leads with an Analysis tab and resolves its tabs by key; the energy page has Overview, History, Flow and Meters; the old cost-only Trends page is a general Analysis page over portfolio, type, category, meter or a meter comparison. Records tabs are paged server-side instead of showing the latest 200. Everything is English and German, light and dark, down to 360px. Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and what the first start after the update does (it rebuilds all analysis data before the web server listens). docs/SDD.md and CLAUDE.md describe the system as it now is. Tests: 1,733 Core and 746 integration, all green, plus an opt-in performance suite with a synthetic 1,000-meter generator. --- CLAUDE.md | 264 +- README.md | 80 +- docs/ANALYSIS_IMPLEMENTATION_NOTE.md | 710 ++++ docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md | 372 +++ docs/RELEASE_NOTES.md | 221 ++ docs/SDD.md | 574 +++- src/App/Analysis/AnalysisChartModel.cs | 570 ++++ src/App/Analysis/AnalysisChartOptions.cs | 168 + src/App/Analysis/AnalysisCsvWriter.cs | 140 + src/App/Analysis/AnalysisDefaults.cs | 113 + src/App/Analysis/AnalysisExport.cs | 270 ++ src/App/Analysis/AnalysisExportEndpoints.cs | 57 + src/App/Analysis/AnalysisMetric.cs | 117 + src/App/Analysis/AnalysisNavigation.cs | 189 ++ src/App/Analysis/AnalysisPeriods.cs | 53 + src/App/Analysis/AnalysisQuery.cs | 725 +++++ src/App/Analysis/AnalysisQueryNotice.cs | 58 + src/App/Analysis/AnalysisTableModel.cs | 324 ++ src/App/Analysis/AttentionItems.cs | 462 +++ src/App/Analysis/ChangeDisplay.cs | 106 + src/App/Analysis/CostChanges.cs | 56 + src/App/Analysis/CostComparisonRequest.cs | 19 + src/App/Analysis/FigureText.cs | 131 + src/App/Analysis/FormulaText.cs | 112 + src/App/Analysis/LoadSequencer.cs | 218 ++ src/App/Analysis/QueryScope.cs | 186 ++ src/App/AnalysisLinks.cs | 113 + src/App/AnalysisPage/AnalysisPageLoader.cs | 309 ++ src/App/AnalysisPage/AnalysisPageOptions.cs | 194 ++ src/App/AnalysisPage/AnalysisPageView.cs | 125 + src/App/AnalysisPage/AnalysisSelection.cs | 334 ++ src/App/Api/ApiEndpoints.cs | 64 +- src/App/BrowserPreferences.cs | 53 + src/App/Components/App.razor | 39 +- src/App/Components/Layout/MainLayout.razor | 38 +- src/App/Components/Layout/NavMenu.razor | 145 +- .../Components/Pages/Admin/EnergyTypes.razor | 15 +- src/App/Components/Pages/Admin/Settings.razor | 153 +- src/App/Components/Pages/Admin/Tariffs.razor | 340 +- .../Pages/AnalysisPage/AnalysisCards.razor | 152 + .../AnalysisPage/AnalysisExplanation.razor | 84 + .../AnalysisExplanation.razor.css | 3 + .../Pages/AnalysisPage/AnalysisNotes.razor | 61 + .../AnalysisPage/AnalysisNotes.razor.css | 3 + .../AnalysisPage/AnalysisPageContent.razor | 172 + .../AnalysisPage/PickerGroupHeader.razor | 20 + .../Pages/AnalysisPage/ScopeSelector.razor | 268 ++ .../AnalysisPage/ScopeSelector.razor.css | 7 + src/App/Components/Pages/Consumables.razor | 291 +- src/App/Components/Pages/Dashboard.razor | 339 +- src/App/Components/Pages/Dashboard.razor.css | 120 + .../Pages/Energy/EnergyFlowTab.razor | 108 + .../Pages/Energy/EnergyFlowTab.razor.css | 62 + .../Pages/Energy/EnergyHistoryTab.razor | 208 ++ .../Pages/Energy/EnergyHistoryTab.razor.css | 32 + .../Pages/Energy/EnergyOverviewTab.razor | 305 ++ .../Pages/Energy/EnergyOverviewTab.razor.css | 31 + .../Energy/ManageConnectionsDialog.razor | 302 ++ .../Energy/ManageConnectionsDialog.razor.css | 75 + src/App/Components/Pages/EnergyView.razor | 397 ++- src/App/Components/Pages/EnergyView.razor.css | 10 + src/App/Components/Pages/MeterDetail.razor | 1569 ++------- .../Pages/MeterPage/AfterNowChip.razor | 4 + .../Pages/MeterPage/ManualReadingDialog.razor | 393 +++ .../Pages/MeterPage/MeterAnalysisTab.razor | 237 ++ .../Pages/MeterPage/MeterCalculationTab.razor | 231 ++ .../MeterPage/MeterCalculationTab.razor.css | 42 + .../Pages/MeterPage/MeterCoverageNote.razor | 232 ++ .../MeterPage/MeterCoverageNote.razor.css | 53 + .../Pages/MeterPage/MeterEventsTab.razor | 144 + .../Pages/MeterPage/MeterHeader.razor | 106 + .../Pages/MeterPage/MeterNormalizedTab.razor | 65 + .../Pages/MeterPage/MeterPath.razor | 36 + .../Pages/MeterPage/MeterReadingsTab.razor | 136 + .../Pages/MeterPage/MeterSourceDialog.razor | 377 +++ .../Pages/MeterPage/MeterSourcesTab.razor | 193 ++ .../Pages/MeterPage/MeterTariffsTab.razor | 134 + .../Pages/MeterPage/QualityChip.razor | 8 + .../Pages/MeterPage/RecordPagerBar.razor | 59 + .../Pages/MeterPage/RecordPagerBar.razor.css | 14 + .../Pages/MeterPage/RecordTabBase.cs | 141 + .../Components/Pages/MeterPage/_Imports.razor | 7 + src/App/Components/Pages/Meters.razor | 252 +- .../Pages/Overview/OverviewChanges.razor | 155 + .../Pages/Overview/OverviewComposition.razor | 253 ++ .../Overview/OverviewComposition.razor.css | 41 + .../Pages/Overview/OverviewCostCard.razor | 61 + .../Pages/Overview/OverviewCostCard.razor.css | 23 + .../Pages/Overview/OverviewCoverage.razor | 93 + .../Pages/Overview/OverviewCoverage.razor.css | 17 + .../Pages/Overview/OverviewDonut.razor | 87 + .../Pages/Overview/OverviewHistory.razor | 65 + .../Pages/Overview/OverviewTypeCard.razor | 143 + .../Pages/Overview/OverviewTypeCard.razor.css | 96 + .../Components/Pages/Overview/OverviewView.cs | 264 ++ .../Components/Pages/Overview/_Imports.razor | 6 + src/App/Components/Pages/Solar.razor | 207 +- .../Pages/Specialized/SolarSiteSection.razor | 363 +++ .../Pages/Specialized/SpecializedViews.cs | 131 + .../Pages/Specialized/TankSection.razor | 306 ++ src/App/Components/Pages/Trends.razor | 252 +- src/App/Components/Pages/Trends.razor.css | 5 + src/App/Components/Routes.razor | 24 +- .../Shared/Analysis/AnalysisBreadcrumbs.razor | 60 + .../Shared/Analysis/AnalysisChart.razor | 188 ++ .../Shared/Analysis/AnalysisTable.razor | 132 + .../Shared/Analysis/AttentionList.razor | 95 + .../Shared/Analysis/ChangeChip.razor | 51 + .../Shared/Analysis/ComparisonSummary.razor | 52 + .../Shared/Analysis/EmptyPeriodState.razor | 60 + .../Shared/Analysis/LoadPanel.razor | 56 + .../Shared/Analysis/MetricCard.razor | 125 + .../Shared/Analysis/PageHeader.razor | 71 + .../Shared/Analysis/PanelError.razor | 31 + .../Shared/Analysis/PendingState.razor | 24 + .../Shared/Analysis/PeriodToolbar.razor | 378 +++ .../Shared/Analysis/ProjectionNote.razor | 24 + .../Shared/Analysis/RefreshIndicator.razor | 25 + .../Shared/Analysis/SeriesContributions.razor | 166 + .../Shared/Analysis/ValueStatus.razor | 62 + .../Components/Shared/Analysis/_Imports.razor | 5 + src/App/Components/Shared/CategoryDonut.razor | 24 - src/App/Components/Shared/DeltaChip.razor | 19 - .../VirtualCalculationEditor.razor | 329 ++ .../VirtualCalculationPreview.razor | 261 ++ src/App/Components/Shared/MeterEditor.razor | 521 ++- .../Shared/MeterLists/MeterList.razor | 248 ++ .../Shared/MeterLists/MeterList.razor.css | 73 + .../Shared/MeterLists/_Imports.razor | 2 + .../Components/Shared/MeterSearchDialog.razor | 16 +- src/App/Components/Shared/SankeyChart.razor | 34 +- src/App/Components/Shared/SeriesChart.razor | 47 - src/App/Components/Shared/TrendChart.razor | 32 - src/App/Components/_Imports.razor | 2 + src/App/Energy/EnergyAnalysis.cs | 248 ++ src/App/Energy/EnergyAnalysisLoader.cs | 80 + src/App/Energy/EnergyHistoryView.cs | 144 + src/App/Energy/FlowText.cs | 113 + src/App/Energy/MeterChanges.cs | 49 + src/App/Energy/MeterListRows.cs | 164 + src/App/Energy/MeterMembership.cs | 70 + src/App/Format.cs | 255 +- src/App/InstanceClock.cs | 21 + src/App/InstanceCurrency.cs | 28 + src/App/Localization/DisplayNames.Analysis.cs | 423 +++ src/App/Localization/DisplayNames.Problems.cs | 63 + src/App/Localization/DisplayNames.cs | 4 +- .../Localization/MeterVaultMudLocalizer.cs | 59 + src/App/Localization/Strings.de.resx | 2883 +++++++++++++++-- src/App/Localization/Strings.resx | 2881 ++++++++++++++-- src/App/MeterDetails/MeterAnalysisLoader.cs | 362 +++ src/App/MeterDetails/MeterDrill.cs | 46 + src/App/MeterDetails/MeterProjection.cs | 80 + src/App/MeterDetails/ReadingEntryVerdict.cs | 58 + src/App/MeterDetails/RecordPager.cs | 49 + src/App/MeterEditing/CalculationDraft.cs | 244 ++ src/App/MeterEditing/CalculationSources.cs | 217 ++ src/App/MeterEditing/MeterEditorText.cs | 154 + .../MeterEditing/VirtualCalculationModel.cs | 210 ++ src/App/MeterEditing/VirtualPreviewPeriod.cs | 54 + src/App/MeterLinks.cs | 96 +- src/App/NavGroups.cs | 98 + src/App/NavState.cs | 18 + src/App/Program.cs | 17 + src/App/TariffEditing/TariffDeepLink.cs | 82 + src/App/TariffEditing/TariffUnitCheck.cs | 274 ++ src/App/TariffEditing/TariffValidity.cs | 40 + src/App/TariffEditing/TariffValue.cs | 47 + src/App/TariffLinks.cs | 121 + src/App/Theme/ThemeState.cs | 61 + src/App/wwwroot/app.css | 127 +- src/App/wwwroot/metervault.js | 19 + src/Core/Analysis/AnalysisContracts.cs | 262 ++ src/Core/Analysis/Costing/CostAmount.cs | 386 +++ src/Core/Analysis/Costing/CostCalculator.cs | 664 ++++ src/Core/Analysis/Costing/CostModels.cs | 388 +++ src/Core/Analysis/Costing/TariffBook.cs | 201 ++ src/Core/Analysis/Coverage/AvailableRange.cs | 70 + src/Core/Analysis/Coverage/BucketCoverage.cs | 51 + src/Core/Analysis/Coverage/CalendarEdges.cs | 84 + src/Core/Analysis/Coverage/CoverageBuilder.cs | 130 + .../Analysis/Coverage/CoverageEvaluator.cs | 490 +++ src/Core/Analysis/Coverage/CoverageRuns.cs | 280 ++ .../Analysis/Coverage/LifecycleCoverage.cs | 74 + src/Core/Analysis/Coverage/MatchedCoverage.cs | 497 +++ src/Core/Analysis/Coverage/ProvenanceRules.cs | 50 + .../Analysis/Coverage/ResolutionClassifier.cs | 88 + src/Core/Analysis/Freshness.cs | 145 + .../Analysis/Quantities/MeterRoleRules.cs | 234 ++ .../Analysis/Quantities/NormalizedQuantity.cs | 276 ++ src/Core/Analysis/Quantities/TariffUnit.cs | 867 +++++ src/Core/Analysis/Quantities/Units.cs | 368 +++ src/Core/Analysis/Rollups/RollupBuilder.cs | 188 ++ src/Core/Analysis/Time/AnalysisTokens.cs | 203 ++ src/Core/Analysis/Time/BucketPlanner.cs | 241 ++ src/Core/Analysis/Time/Change.cs | 80 + src/Core/Analysis/Time/ComparisonResolver.cs | 538 +++ src/Core/Analysis/Time/LegacyPeriods.cs | 117 + src/Core/Analysis/Time/LocalCalendar.cs | 106 + src/Core/Analysis/Time/PeriodBucket.cs | 60 + src/Core/Analysis/Time/PeriodResolver.cs | 269 ++ src/Core/Analysis/Totals/CategoryCover.cs | 162 + src/Core/Analysis/Totals/MeasureValues.cs | 125 + src/Core/Analysis/Totals/TotalsGraph.cs | 358 ++ src/Core/Analysis/Totals/TotalsInputs.cs | 118 + src/Core/Analysis/Totals/TotalsModels.cs | 363 +++ src/Core/Analysis/Totals/TotalsPolicy.cs | 162 + src/Core/Analysis/Totals/TotalsRun.cs | 655 ++++ src/Core/Analysis/Virtual/DependencyGraph.cs | 430 +++ src/Core/Analysis/Virtual/Formula.cs | 438 +++ src/Core/Analysis/Virtual/FormulaNode.cs | 97 + src/Core/Analysis/Virtual/FormulaParser.cs | 497 +++ .../Virtual/LegacyVirtualDerivation.cs | 257 ++ src/Core/Analysis/Virtual/MeterCatalog.cs | 67 + .../Analysis/Virtual/VirtualDefinition.cs | 104 + .../Analysis/Virtual/VirtualDefinitionJson.cs | 350 ++ src/Core/Analysis/Virtual/VirtualEvaluator.cs | 720 ++++ src/Core/Analysis/Virtual/VirtualValidator.cs | 539 +++ src/Core/Domain/Consumption.cs | 68 + .../Expressions/ExpressionEvaluator.cs | 174 - src/Core/Normalization/GapAttribution.cs | 78 +- src/Core/Normalization/MeterConfig.cs | 9 + src/Core/Normalization/NormalizationEngine.cs | 33 +- .../ConsumableBalanceNormalizer.cs | 27 +- .../Normalizers/CounterNormalizerBase.cs | 57 +- .../Normalizers/DirectDeltaNormalizer.cs | 29 +- .../Normalizers/InstantRateNormalizer.cs | 122 +- .../Normalizers/RuntimeCounterNormalizer.cs | 35 +- .../Normalizers/VirtualNormalizer.cs | 66 - src/Core/Normalization/SourceInterval.cs | 69 + .../Analysis/AnalysisCatalog.cs | 374 +++ src/Infrastructure/Analysis/AnalysisModels.cs | 487 +++ .../Analysis/AnalysisQueries.cs | 379 +++ src/Infrastructure/Analysis/AnalysisReader.cs | 184 ++ src/Infrastructure/Analysis/AnalysisRun.cs | 1285 ++++++++ src/Infrastructure/Analysis/LeafData.cs | 530 +++ .../Analysis/MeterDraftAnalysis.cs | 239 ++ .../Analysis/MeterRoleAssignment.cs | 84 + .../Analysis/VirtualDefinitionUpgrade.cs | 174 + .../Analysis/VirtualMeterService.cs | 51 + src/Infrastructure/Backup/ExportDocument.cs | 9 +- src/Infrastructure/Backup/ExportService.cs | 64 +- src/Infrastructure/Costing/BillRun.cs | 1559 +++++++++ .../Costing/CostAnalysisModels.cs | 483 +++ src/Infrastructure/Costing/CostModels.cs | 72 +- src/Infrastructure/Costing/CostReader.cs | 99 + src/Infrastructure/Costing/CostService.cs | 293 +- .../Dashboard/ConsumableModels.cs | 209 +- .../Dashboard/ConsumableService.cs | 355 +- .../Dashboard/DashboardModels.cs | 90 +- .../Dashboard/DashboardService.cs | 566 +++- src/Infrastructure/Dashboard/FlowModels.cs | 82 +- src/Infrastructure/Dashboard/FlowService.cs | 469 ++- .../Dashboard/MeterDetailModels.cs | 205 +- .../Dashboard/MeterDetailService.cs | 430 ++- .../Dashboard/MeterLinkService.cs | 367 +++ .../Dashboard/MeterPeriodService.cs | 159 - .../Dashboard/OverviewModels.cs | 367 +++ src/Infrastructure/Dashboard/SolarModels.cs | 200 +- src/Infrastructure/Dashboard/SolarService.cs | 723 ++++- src/Infrastructure/Dashboard/TankLevels.cs | 118 + src/Infrastructure/DependencyInjection.cs | 9 +- .../Import/ReferenceDataImporter.cs | 22 +- .../Normalization/AnalysisDataWriter.cs | 213 ++ .../Normalization/MeterConfigFactory.cs | 1 + .../Normalization/NormalizationService.cs | 88 +- .../Normalization/NormalizationUpgrade.cs | 111 +- .../Persistence/Analysis/AnalysisEntities.cs | 187 ++ .../Persistence/EntityDeletion.cs | 77 + .../Persistence/MeterVaultDbContext.cs | 64 + ...20260919090259_AnalysisRollups.Designer.cs | 1145 +++++++ .../20260919090259_AnalysisRollups.cs | 201 ++ .../MeterVaultDbContextModelSnapshot.cs | 214 ++ tests/Core.Tests/Analysis/AnalysisClock.cs | 38 + tests/Core.Tests/Analysis/AnalysisTestTime.cs | 26 + .../Analysis/AnalysisTokensTests.cs | 226 ++ .../Core.Tests/Analysis/BucketPlannerTests.cs | 502 +++ .../Core.Tests/Analysis/CategoryCoverTests.cs | 319 ++ tests/Core.Tests/Analysis/ChangeTests.cs | 145 + .../Analysis/ComparisonResolverTests.cs | 832 +++++ .../Analysis/Costing/CostAmountTests.cs | 167 + .../Costing/CostCalculatorBillTests.cs | 180 + .../Costing/CostCalculatorCoverageTests.cs | 424 +++ .../Costing/CostCalculatorManualCostTests.cs | 107 + .../Costing/CostCalculatorPricingTests.cs | 268 ++ .../CostCalculatorStandingChargeTests.cs | 347 ++ .../Costing/CostingTariffBookTests.cs | 138 + .../Analysis/Costing/CostingTestData.cs | 153 + .../Analysis/CoverageBuilderTests.cs | 410 +++ .../Analysis/CoverageEvaluatorTests.cs | 809 +++++ .../Core.Tests/Analysis/CoverageRunsTests.cs | 343 ++ tests/Core.Tests/Analysis/CoverageTestData.cs | 134 + .../Analysis/DependencyGraphTests.cs | 108 + .../Analysis/EngineIntervalTests.cs | 761 +++++ .../Core.Tests/Analysis/FormulaParserTests.cs | 163 + tests/Core.Tests/Analysis/FormulaTests.cs | 158 + .../Analysis/FreshnessRulesTests.cs | 106 + .../Core.Tests/Analysis/LegacyPeriodsTests.cs | 166 + .../Analysis/LegacyVirtualDerivationTests.cs | 250 ++ .../Analysis/MatchedCoverageTests.cs | 484 +++ .../Analysis/MeterRoleRulesTests.cs | 281 ++ .../Analysis/NormalizedQuantityTests.cs | 482 +++ .../Analysis/PeriodResolverTests.cs | 425 +++ .../Analysis/ProvenanceRulesTests.cs | 62 + .../Core.Tests/Analysis/ReaderSupportTests.cs | 219 ++ .../Analysis/ResolutionClassifierTests.cs | 101 + .../Core.Tests/Analysis/RollupBuilderTests.cs | 278 ++ .../Analysis/SeparatelyBilledSubmeterTests.cs | 260 ++ tests/Core.Tests/Analysis/TariffUnitTests.cs | 744 +++++ .../Core.Tests/Analysis/TotalsPolicyTests.cs | 940 ++++++ tests/Core.Tests/Analysis/TotalsSeed.cs | 105 + tests/Core.Tests/Analysis/UnitsTests.cs | 349 ++ .../Analysis/VirtualDefinitionJsonTests.cs | 250 ++ .../Analysis/VirtualEvaluatorTests.cs | 516 +++ tests/Core.Tests/Analysis/VirtualFixtures.cs | 115 + .../Analysis/VirtualValidatorTests.cs | 353 ++ tests/Core.Tests/ExpressionEvaluatorTests.cs | 36 - tests/Core.Tests/VirtualMeterTests.cs | 79 +- .../Analysis/AnalysisCatalogTests.cs | 32 + .../Analysis/AnalysisChartModelTests.cs | 318 ++ .../Analysis/AnalysisComponentRenderTests.cs | 348 ++ .../Analysis/AnalysisCsvWriterTests.cs | 88 + .../Analysis/AnalysisDataTests.cs | 813 +++++ .../Analysis/AnalysisExportEndpointTests.cs | 236 ++ .../Analysis/AnalysisNavigationTests.cs | 165 + .../Analysis/AnalysisPageLoaderTests.cs | 272 ++ .../Analysis/AnalysisPageSelectionTests.cs | 277 ++ .../Analysis/AnalysisQueryTests.cs | 315 ++ .../Analysis/AnalysisReaderTests.cs | 1170 +++++++ .../Analysis/AnalysisTableModelTests.cs | 233 ++ .../Analysis/AnalysisUiTestData.cs | 103 + .../Analysis/AppLinkTests.cs | 160 + .../Analysis/AttentionItemsTests.cs | 226 ++ .../Analysis/CostConsistencyTests.cs | 111 + .../Analysis/EnergyPageTests.cs | 334 ++ .../Analysis/LoadSequencerTests.cs | 110 + .../Analysis/MeterRoleAssignmentTests.cs | 129 + .../Analysis/PageIntegrationTests.cs | 98 + .../Analysis/ShellPreferenceTests.cs | 73 + .../Analysis/VirtualManagementTests.cs | 229 ++ tests/Integration.Tests/ApiContractTests.cs | 478 +++ .../Costing/CostReaderTests.cs | 466 +++ .../Costing/CostReconciliationTests.cs | 43 +- .../Costing/CostReviewFixTests.cs | 317 ++ .../Integration.Tests/Costing/CostSandbox.cs | 281 ++ .../Costing/DashboardServicesTests.cs | 246 ++ .../Costing/SeededBillTests.cs | 341 ++ .../Integration.Tests/DashboardRenderTests.cs | 292 +- .../Editor/AdminPagesRenderTests.cs | 104 + .../Editor/MeterDraftPreviewTests.cs | 292 ++ .../Editor/MeterEditorLogicTests.cs | 464 +++ .../Editor/TariffEditingTests.cs | 240 ++ .../Integration.Tests/EnergyTypePageTests.cs | 293 ++ .../Integration.Tests/ExportRoundTripTests.cs | 205 +- tests/Integration.Tests/FixedTimeProvider.cs | 9 + tests/Integration.Tests/FlowServiceTests.cs | 418 ++- .../Ingestion/MonthAttributionTests.cs | 7 +- .../Integration.Tests/LocalTimeEntryTests.cs | 23 +- .../Localization/EnumDisplayNameTests.cs | 39 +- .../Localization/FormatCultureTests.cs | 151 +- .../MeterLinkServiceTests.cs | 103 + .../MeterPage/MeterAnalysisLoaderTests.cs | 267 ++ .../MeterPage/MeterDetailServiceTests.cs | 286 ++ .../MeterPage/MeterPageLogicTests.cs | 281 ++ .../MeterPage/MeterSourcesRenderTests.cs | 63 + .../MeterPeriodServiceTests.cs | 158 - .../Overview/OverviewDataTests.cs | 241 ++ .../Overview/OverviewLogicTests.cs | 299 ++ .../Overview/OverviewPageTests.cs | 174 + .../Performance/CommandCounter.cs | 108 + .../Performance/PerfDatabase.cs | 281 ++ .../Performance/PerfReport.cs | 226 ++ .../Performance/PerfSettings.cs | 95 + .../Performance/ReaderTimingTests.cs | 557 ++++ .../Performance/SyntheticDataset.cs | 1030 ++++++ .../Performance/SyntheticLoadTests.cs | 49 + .../Reconciliation/CoverageOfFixturesTests.cs | 127 + .../ElectricityReconciliationTests.cs | 78 +- .../Reconciliation/ReconciliationSupport.cs | 9 +- tests/Integration.Tests/SchemaTests.cs | 37 + .../Specialized/ConsumableServiceTests.cs | 250 ++ .../Specialized/SolarFiguresTests.cs | 101 + .../Specialized/SolarServiceTests.cs | 321 ++ .../Specialized/TankLevelsTests.cs | 104 + 384 files changed, 82753 insertions(+), 4518 deletions(-) create mode 100644 docs/ANALYSIS_IMPLEMENTATION_NOTE.md create mode 100644 docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md create mode 100644 docs/RELEASE_NOTES.md create mode 100644 src/App/Analysis/AnalysisChartModel.cs create mode 100644 src/App/Analysis/AnalysisChartOptions.cs create mode 100644 src/App/Analysis/AnalysisCsvWriter.cs create mode 100644 src/App/Analysis/AnalysisDefaults.cs create mode 100644 src/App/Analysis/AnalysisExport.cs create mode 100644 src/App/Analysis/AnalysisExportEndpoints.cs create mode 100644 src/App/Analysis/AnalysisMetric.cs create mode 100644 src/App/Analysis/AnalysisNavigation.cs create mode 100644 src/App/Analysis/AnalysisPeriods.cs create mode 100644 src/App/Analysis/AnalysisQuery.cs create mode 100644 src/App/Analysis/AnalysisQueryNotice.cs create mode 100644 src/App/Analysis/AnalysisTableModel.cs create mode 100644 src/App/Analysis/AttentionItems.cs create mode 100644 src/App/Analysis/ChangeDisplay.cs create mode 100644 src/App/Analysis/CostChanges.cs create mode 100644 src/App/Analysis/CostComparisonRequest.cs create mode 100644 src/App/Analysis/FigureText.cs create mode 100644 src/App/Analysis/FormulaText.cs create mode 100644 src/App/Analysis/LoadSequencer.cs create mode 100644 src/App/Analysis/QueryScope.cs create mode 100644 src/App/AnalysisLinks.cs create mode 100644 src/App/AnalysisPage/AnalysisPageLoader.cs create mode 100644 src/App/AnalysisPage/AnalysisPageOptions.cs create mode 100644 src/App/AnalysisPage/AnalysisPageView.cs create mode 100644 src/App/AnalysisPage/AnalysisSelection.cs create mode 100644 src/App/BrowserPreferences.cs create mode 100644 src/App/Components/Pages/AnalysisPage/AnalysisCards.razor create mode 100644 src/App/Components/Pages/AnalysisPage/AnalysisExplanation.razor create mode 100644 src/App/Components/Pages/AnalysisPage/AnalysisExplanation.razor.css create mode 100644 src/App/Components/Pages/AnalysisPage/AnalysisNotes.razor create mode 100644 src/App/Components/Pages/AnalysisPage/AnalysisNotes.razor.css create mode 100644 src/App/Components/Pages/AnalysisPage/AnalysisPageContent.razor create mode 100644 src/App/Components/Pages/AnalysisPage/PickerGroupHeader.razor create mode 100644 src/App/Components/Pages/AnalysisPage/ScopeSelector.razor create mode 100644 src/App/Components/Pages/AnalysisPage/ScopeSelector.razor.css create mode 100644 src/App/Components/Pages/Dashboard.razor.css create mode 100644 src/App/Components/Pages/Energy/EnergyFlowTab.razor create mode 100644 src/App/Components/Pages/Energy/EnergyFlowTab.razor.css create mode 100644 src/App/Components/Pages/Energy/EnergyHistoryTab.razor create mode 100644 src/App/Components/Pages/Energy/EnergyHistoryTab.razor.css create mode 100644 src/App/Components/Pages/Energy/EnergyOverviewTab.razor create mode 100644 src/App/Components/Pages/Energy/EnergyOverviewTab.razor.css create mode 100644 src/App/Components/Pages/Energy/ManageConnectionsDialog.razor create mode 100644 src/App/Components/Pages/Energy/ManageConnectionsDialog.razor.css create mode 100644 src/App/Components/Pages/EnergyView.razor.css create mode 100644 src/App/Components/Pages/MeterPage/AfterNowChip.razor create mode 100644 src/App/Components/Pages/MeterPage/ManualReadingDialog.razor create mode 100644 src/App/Components/Pages/MeterPage/MeterAnalysisTab.razor create mode 100644 src/App/Components/Pages/MeterPage/MeterCalculationTab.razor create mode 100644 src/App/Components/Pages/MeterPage/MeterCalculationTab.razor.css create mode 100644 src/App/Components/Pages/MeterPage/MeterCoverageNote.razor create mode 100644 src/App/Components/Pages/MeterPage/MeterCoverageNote.razor.css create mode 100644 src/App/Components/Pages/MeterPage/MeterEventsTab.razor create mode 100644 src/App/Components/Pages/MeterPage/MeterHeader.razor create mode 100644 src/App/Components/Pages/MeterPage/MeterNormalizedTab.razor create mode 100644 src/App/Components/Pages/MeterPage/MeterPath.razor create mode 100644 src/App/Components/Pages/MeterPage/MeterReadingsTab.razor create mode 100644 src/App/Components/Pages/MeterPage/MeterSourceDialog.razor create mode 100644 src/App/Components/Pages/MeterPage/MeterSourcesTab.razor create mode 100644 src/App/Components/Pages/MeterPage/MeterTariffsTab.razor create mode 100644 src/App/Components/Pages/MeterPage/QualityChip.razor create mode 100644 src/App/Components/Pages/MeterPage/RecordPagerBar.razor create mode 100644 src/App/Components/Pages/MeterPage/RecordPagerBar.razor.css create mode 100644 src/App/Components/Pages/MeterPage/RecordTabBase.cs create mode 100644 src/App/Components/Pages/MeterPage/_Imports.razor create mode 100644 src/App/Components/Pages/Overview/OverviewChanges.razor create mode 100644 src/App/Components/Pages/Overview/OverviewComposition.razor create mode 100644 src/App/Components/Pages/Overview/OverviewComposition.razor.css create mode 100644 src/App/Components/Pages/Overview/OverviewCostCard.razor create mode 100644 src/App/Components/Pages/Overview/OverviewCostCard.razor.css create mode 100644 src/App/Components/Pages/Overview/OverviewCoverage.razor create mode 100644 src/App/Components/Pages/Overview/OverviewCoverage.razor.css create mode 100644 src/App/Components/Pages/Overview/OverviewDonut.razor create mode 100644 src/App/Components/Pages/Overview/OverviewHistory.razor create mode 100644 src/App/Components/Pages/Overview/OverviewTypeCard.razor create mode 100644 src/App/Components/Pages/Overview/OverviewTypeCard.razor.css create mode 100644 src/App/Components/Pages/Overview/OverviewView.cs create mode 100644 src/App/Components/Pages/Overview/_Imports.razor create mode 100644 src/App/Components/Pages/Specialized/SolarSiteSection.razor create mode 100644 src/App/Components/Pages/Specialized/SpecializedViews.cs create mode 100644 src/App/Components/Pages/Specialized/TankSection.razor create mode 100644 src/App/Components/Pages/Trends.razor.css create mode 100644 src/App/Components/Shared/Analysis/AnalysisBreadcrumbs.razor create mode 100644 src/App/Components/Shared/Analysis/AnalysisChart.razor create mode 100644 src/App/Components/Shared/Analysis/AnalysisTable.razor create mode 100644 src/App/Components/Shared/Analysis/AttentionList.razor create mode 100644 src/App/Components/Shared/Analysis/ChangeChip.razor create mode 100644 src/App/Components/Shared/Analysis/ComparisonSummary.razor create mode 100644 src/App/Components/Shared/Analysis/EmptyPeriodState.razor create mode 100644 src/App/Components/Shared/Analysis/LoadPanel.razor create mode 100644 src/App/Components/Shared/Analysis/MetricCard.razor create mode 100644 src/App/Components/Shared/Analysis/PageHeader.razor create mode 100644 src/App/Components/Shared/Analysis/PanelError.razor create mode 100644 src/App/Components/Shared/Analysis/PendingState.razor create mode 100644 src/App/Components/Shared/Analysis/PeriodToolbar.razor create mode 100644 src/App/Components/Shared/Analysis/ProjectionNote.razor create mode 100644 src/App/Components/Shared/Analysis/RefreshIndicator.razor create mode 100644 src/App/Components/Shared/Analysis/SeriesContributions.razor create mode 100644 src/App/Components/Shared/Analysis/ValueStatus.razor create mode 100644 src/App/Components/Shared/Analysis/_Imports.razor delete mode 100644 src/App/Components/Shared/CategoryDonut.razor delete mode 100644 src/App/Components/Shared/DeltaChip.razor create mode 100644 src/App/Components/Shared/MeterEditing/VirtualCalculationEditor.razor create mode 100644 src/App/Components/Shared/MeterEditing/VirtualCalculationPreview.razor create mode 100644 src/App/Components/Shared/MeterLists/MeterList.razor create mode 100644 src/App/Components/Shared/MeterLists/MeterList.razor.css create mode 100644 src/App/Components/Shared/MeterLists/_Imports.razor delete mode 100644 src/App/Components/Shared/SeriesChart.razor delete mode 100644 src/App/Components/Shared/TrendChart.razor create mode 100644 src/App/Energy/EnergyAnalysis.cs create mode 100644 src/App/Energy/EnergyAnalysisLoader.cs create mode 100644 src/App/Energy/EnergyHistoryView.cs create mode 100644 src/App/Energy/FlowText.cs create mode 100644 src/App/Energy/MeterChanges.cs create mode 100644 src/App/Energy/MeterListRows.cs create mode 100644 src/App/Energy/MeterMembership.cs create mode 100644 src/App/InstanceClock.cs create mode 100644 src/App/InstanceCurrency.cs create mode 100644 src/App/Localization/DisplayNames.Analysis.cs create mode 100644 src/App/Localization/DisplayNames.Problems.cs create mode 100644 src/App/Localization/MeterVaultMudLocalizer.cs create mode 100644 src/App/MeterDetails/MeterAnalysisLoader.cs create mode 100644 src/App/MeterDetails/MeterDrill.cs create mode 100644 src/App/MeterDetails/MeterProjection.cs create mode 100644 src/App/MeterDetails/ReadingEntryVerdict.cs create mode 100644 src/App/MeterDetails/RecordPager.cs create mode 100644 src/App/MeterEditing/CalculationDraft.cs create mode 100644 src/App/MeterEditing/CalculationSources.cs create mode 100644 src/App/MeterEditing/MeterEditorText.cs create mode 100644 src/App/MeterEditing/VirtualCalculationModel.cs create mode 100644 src/App/MeterEditing/VirtualPreviewPeriod.cs create mode 100644 src/App/NavGroups.cs create mode 100644 src/App/TariffEditing/TariffDeepLink.cs create mode 100644 src/App/TariffEditing/TariffUnitCheck.cs create mode 100644 src/App/TariffEditing/TariffValidity.cs create mode 100644 src/App/TariffEditing/TariffValue.cs create mode 100644 src/App/TariffLinks.cs create mode 100644 src/App/Theme/ThemeState.cs create mode 100644 src/App/wwwroot/metervault.js create mode 100644 src/Core/Analysis/AnalysisContracts.cs create mode 100644 src/Core/Analysis/Costing/CostAmount.cs create mode 100644 src/Core/Analysis/Costing/CostCalculator.cs create mode 100644 src/Core/Analysis/Costing/CostModels.cs create mode 100644 src/Core/Analysis/Costing/TariffBook.cs create mode 100644 src/Core/Analysis/Coverage/AvailableRange.cs create mode 100644 src/Core/Analysis/Coverage/BucketCoverage.cs create mode 100644 src/Core/Analysis/Coverage/CalendarEdges.cs create mode 100644 src/Core/Analysis/Coverage/CoverageBuilder.cs create mode 100644 src/Core/Analysis/Coverage/CoverageEvaluator.cs create mode 100644 src/Core/Analysis/Coverage/CoverageRuns.cs create mode 100644 src/Core/Analysis/Coverage/LifecycleCoverage.cs create mode 100644 src/Core/Analysis/Coverage/MatchedCoverage.cs create mode 100644 src/Core/Analysis/Coverage/ProvenanceRules.cs create mode 100644 src/Core/Analysis/Coverage/ResolutionClassifier.cs create mode 100644 src/Core/Analysis/Freshness.cs create mode 100644 src/Core/Analysis/Quantities/MeterRoleRules.cs create mode 100644 src/Core/Analysis/Quantities/NormalizedQuantity.cs create mode 100644 src/Core/Analysis/Quantities/TariffUnit.cs create mode 100644 src/Core/Analysis/Quantities/Units.cs create mode 100644 src/Core/Analysis/Rollups/RollupBuilder.cs create mode 100644 src/Core/Analysis/Time/AnalysisTokens.cs create mode 100644 src/Core/Analysis/Time/BucketPlanner.cs create mode 100644 src/Core/Analysis/Time/Change.cs create mode 100644 src/Core/Analysis/Time/ComparisonResolver.cs create mode 100644 src/Core/Analysis/Time/LegacyPeriods.cs create mode 100644 src/Core/Analysis/Time/LocalCalendar.cs create mode 100644 src/Core/Analysis/Time/PeriodBucket.cs create mode 100644 src/Core/Analysis/Time/PeriodResolver.cs create mode 100644 src/Core/Analysis/Totals/CategoryCover.cs create mode 100644 src/Core/Analysis/Totals/MeasureValues.cs create mode 100644 src/Core/Analysis/Totals/TotalsGraph.cs create mode 100644 src/Core/Analysis/Totals/TotalsInputs.cs create mode 100644 src/Core/Analysis/Totals/TotalsModels.cs create mode 100644 src/Core/Analysis/Totals/TotalsPolicy.cs create mode 100644 src/Core/Analysis/Totals/TotalsRun.cs create mode 100644 src/Core/Analysis/Virtual/DependencyGraph.cs create mode 100644 src/Core/Analysis/Virtual/Formula.cs create mode 100644 src/Core/Analysis/Virtual/FormulaNode.cs create mode 100644 src/Core/Analysis/Virtual/FormulaParser.cs create mode 100644 src/Core/Analysis/Virtual/LegacyVirtualDerivation.cs create mode 100644 src/Core/Analysis/Virtual/MeterCatalog.cs create mode 100644 src/Core/Analysis/Virtual/VirtualDefinition.cs create mode 100644 src/Core/Analysis/Virtual/VirtualDefinitionJson.cs create mode 100644 src/Core/Analysis/Virtual/VirtualEvaluator.cs create mode 100644 src/Core/Analysis/Virtual/VirtualValidator.cs delete mode 100644 src/Core/Normalization/Expressions/ExpressionEvaluator.cs delete mode 100644 src/Core/Normalization/Normalizers/VirtualNormalizer.cs create mode 100644 src/Core/Normalization/SourceInterval.cs create mode 100644 src/Infrastructure/Analysis/AnalysisCatalog.cs create mode 100644 src/Infrastructure/Analysis/AnalysisModels.cs create mode 100644 src/Infrastructure/Analysis/AnalysisQueries.cs create mode 100644 src/Infrastructure/Analysis/AnalysisReader.cs create mode 100644 src/Infrastructure/Analysis/AnalysisRun.cs create mode 100644 src/Infrastructure/Analysis/LeafData.cs create mode 100644 src/Infrastructure/Analysis/MeterDraftAnalysis.cs create mode 100644 src/Infrastructure/Analysis/MeterRoleAssignment.cs create mode 100644 src/Infrastructure/Analysis/VirtualDefinitionUpgrade.cs create mode 100644 src/Infrastructure/Analysis/VirtualMeterService.cs create mode 100644 src/Infrastructure/Costing/BillRun.cs create mode 100644 src/Infrastructure/Costing/CostAnalysisModels.cs create mode 100644 src/Infrastructure/Costing/CostReader.cs create mode 100644 src/Infrastructure/Dashboard/MeterLinkService.cs delete mode 100644 src/Infrastructure/Dashboard/MeterPeriodService.cs create mode 100644 src/Infrastructure/Dashboard/OverviewModels.cs create mode 100644 src/Infrastructure/Dashboard/TankLevels.cs create mode 100644 src/Infrastructure/Normalization/AnalysisDataWriter.cs create mode 100644 src/Infrastructure/Persistence/Analysis/AnalysisEntities.cs create mode 100644 src/Infrastructure/Persistence/EntityDeletion.cs create mode 100644 src/Infrastructure/Persistence/Migrations/20260919090259_AnalysisRollups.Designer.cs create mode 100644 src/Infrastructure/Persistence/Migrations/20260919090259_AnalysisRollups.cs create mode 100644 tests/Core.Tests/Analysis/AnalysisClock.cs create mode 100644 tests/Core.Tests/Analysis/AnalysisTestTime.cs create mode 100644 tests/Core.Tests/Analysis/AnalysisTokensTests.cs create mode 100644 tests/Core.Tests/Analysis/BucketPlannerTests.cs create mode 100644 tests/Core.Tests/Analysis/CategoryCoverTests.cs create mode 100644 tests/Core.Tests/Analysis/ChangeTests.cs create mode 100644 tests/Core.Tests/Analysis/ComparisonResolverTests.cs create mode 100644 tests/Core.Tests/Analysis/Costing/CostAmountTests.cs create mode 100644 tests/Core.Tests/Analysis/Costing/CostCalculatorBillTests.cs create mode 100644 tests/Core.Tests/Analysis/Costing/CostCalculatorCoverageTests.cs create mode 100644 tests/Core.Tests/Analysis/Costing/CostCalculatorManualCostTests.cs create mode 100644 tests/Core.Tests/Analysis/Costing/CostCalculatorPricingTests.cs create mode 100644 tests/Core.Tests/Analysis/Costing/CostCalculatorStandingChargeTests.cs create mode 100644 tests/Core.Tests/Analysis/Costing/CostingTariffBookTests.cs create mode 100644 tests/Core.Tests/Analysis/Costing/CostingTestData.cs create mode 100644 tests/Core.Tests/Analysis/CoverageBuilderTests.cs create mode 100644 tests/Core.Tests/Analysis/CoverageEvaluatorTests.cs create mode 100644 tests/Core.Tests/Analysis/CoverageRunsTests.cs create mode 100644 tests/Core.Tests/Analysis/CoverageTestData.cs create mode 100644 tests/Core.Tests/Analysis/DependencyGraphTests.cs create mode 100644 tests/Core.Tests/Analysis/EngineIntervalTests.cs create mode 100644 tests/Core.Tests/Analysis/FormulaParserTests.cs create mode 100644 tests/Core.Tests/Analysis/FormulaTests.cs create mode 100644 tests/Core.Tests/Analysis/FreshnessRulesTests.cs create mode 100644 tests/Core.Tests/Analysis/LegacyPeriodsTests.cs create mode 100644 tests/Core.Tests/Analysis/LegacyVirtualDerivationTests.cs create mode 100644 tests/Core.Tests/Analysis/MatchedCoverageTests.cs create mode 100644 tests/Core.Tests/Analysis/MeterRoleRulesTests.cs create mode 100644 tests/Core.Tests/Analysis/NormalizedQuantityTests.cs create mode 100644 tests/Core.Tests/Analysis/PeriodResolverTests.cs create mode 100644 tests/Core.Tests/Analysis/ProvenanceRulesTests.cs create mode 100644 tests/Core.Tests/Analysis/ReaderSupportTests.cs create mode 100644 tests/Core.Tests/Analysis/ResolutionClassifierTests.cs create mode 100644 tests/Core.Tests/Analysis/RollupBuilderTests.cs create mode 100644 tests/Core.Tests/Analysis/SeparatelyBilledSubmeterTests.cs create mode 100644 tests/Core.Tests/Analysis/TariffUnitTests.cs create mode 100644 tests/Core.Tests/Analysis/TotalsPolicyTests.cs create mode 100644 tests/Core.Tests/Analysis/TotalsSeed.cs create mode 100644 tests/Core.Tests/Analysis/UnitsTests.cs create mode 100644 tests/Core.Tests/Analysis/VirtualDefinitionJsonTests.cs create mode 100644 tests/Core.Tests/Analysis/VirtualEvaluatorTests.cs create mode 100644 tests/Core.Tests/Analysis/VirtualFixtures.cs create mode 100644 tests/Core.Tests/Analysis/VirtualValidatorTests.cs delete mode 100644 tests/Core.Tests/ExpressionEvaluatorTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisCatalogTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisChartModelTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisComponentRenderTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisCsvWriterTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisDataTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisExportEndpointTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisNavigationTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisPageLoaderTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisPageSelectionTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisQueryTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisReaderTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisTableModelTests.cs create mode 100644 tests/Integration.Tests/Analysis/AnalysisUiTestData.cs create mode 100644 tests/Integration.Tests/Analysis/AppLinkTests.cs create mode 100644 tests/Integration.Tests/Analysis/AttentionItemsTests.cs create mode 100644 tests/Integration.Tests/Analysis/CostConsistencyTests.cs create mode 100644 tests/Integration.Tests/Analysis/EnergyPageTests.cs create mode 100644 tests/Integration.Tests/Analysis/LoadSequencerTests.cs create mode 100644 tests/Integration.Tests/Analysis/MeterRoleAssignmentTests.cs create mode 100644 tests/Integration.Tests/Analysis/PageIntegrationTests.cs create mode 100644 tests/Integration.Tests/Analysis/ShellPreferenceTests.cs create mode 100644 tests/Integration.Tests/Analysis/VirtualManagementTests.cs create mode 100644 tests/Integration.Tests/ApiContractTests.cs create mode 100644 tests/Integration.Tests/Costing/CostReaderTests.cs create mode 100644 tests/Integration.Tests/Costing/CostReviewFixTests.cs create mode 100644 tests/Integration.Tests/Costing/CostSandbox.cs create mode 100644 tests/Integration.Tests/Costing/DashboardServicesTests.cs create mode 100644 tests/Integration.Tests/Costing/SeededBillTests.cs create mode 100644 tests/Integration.Tests/Editor/AdminPagesRenderTests.cs create mode 100644 tests/Integration.Tests/Editor/MeterDraftPreviewTests.cs create mode 100644 tests/Integration.Tests/Editor/MeterEditorLogicTests.cs create mode 100644 tests/Integration.Tests/Editor/TariffEditingTests.cs create mode 100644 tests/Integration.Tests/EnergyTypePageTests.cs create mode 100644 tests/Integration.Tests/FixedTimeProvider.cs create mode 100644 tests/Integration.Tests/MeterLinkServiceTests.cs create mode 100644 tests/Integration.Tests/MeterPage/MeterAnalysisLoaderTests.cs create mode 100644 tests/Integration.Tests/MeterPage/MeterDetailServiceTests.cs create mode 100644 tests/Integration.Tests/MeterPage/MeterPageLogicTests.cs create mode 100644 tests/Integration.Tests/MeterPage/MeterSourcesRenderTests.cs delete mode 100644 tests/Integration.Tests/MeterPeriodServiceTests.cs create mode 100644 tests/Integration.Tests/Overview/OverviewDataTests.cs create mode 100644 tests/Integration.Tests/Overview/OverviewLogicTests.cs create mode 100644 tests/Integration.Tests/Overview/OverviewPageTests.cs create mode 100644 tests/Integration.Tests/Performance/CommandCounter.cs create mode 100644 tests/Integration.Tests/Performance/PerfDatabase.cs create mode 100644 tests/Integration.Tests/Performance/PerfReport.cs create mode 100644 tests/Integration.Tests/Performance/PerfSettings.cs create mode 100644 tests/Integration.Tests/Performance/ReaderTimingTests.cs create mode 100644 tests/Integration.Tests/Performance/SyntheticDataset.cs create mode 100644 tests/Integration.Tests/Performance/SyntheticLoadTests.cs create mode 100644 tests/Integration.Tests/Reconciliation/CoverageOfFixturesTests.cs create mode 100644 tests/Integration.Tests/Specialized/ConsumableServiceTests.cs create mode 100644 tests/Integration.Tests/Specialized/SolarFiguresTests.cs create mode 100644 tests/Integration.Tests/Specialized/SolarServiceTests.cs create mode 100644 tests/Integration.Tests/Specialized/TankLevelsTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index afb8a57..b8f725c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,39 +6,101 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**. -**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~350 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets as an env-var reference or typed in and encrypted at rest). **Manual readings** are entered from the meter-detail Readings tab ("Add reading"): a touch-first dialog prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter, a live parsed-value + delta-since-last readout, and the decrease guard surfaced before saving. It goes through `IngestionService.IngestByMeterAsync(quality: Manual)`, so it is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest — the layout of that dialog deliberately reserves fixed space for its verdict line, because anything that reflows moves the keys out from under the user's thumb mid-entry. `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll, or — with the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`) — `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time (the poll worker skips WS endpoints, so each is served once; `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic). `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch` (the `/import` page lists batches with one-click revert). The `instant_rate` mode is normalized (`InstantRateNormalizer`: rate integrated over time, trapezoidal). **Meter events from the UI** (swap, counter reset, tank level, delivery, note) go through `MeterEventService` (`Infrastructure/Ingestion`), never ad-hoc inserts: `MeterEventRules.RecordableFor(mode)` (Core) decides which events a mode offers (no `Correction` — nothing reads it), `Validate` gives the dialog the same verdict the save reaches, and every record/delete recomputes the meter in one transaction. A swap/reset is stored as the event at T **plus a manual reading of the new register's start value at exactly T** (flagged `MeterSwap`/`CounterReset`) — the boundary window is `(prevReading, reading]`, so this books the old tail at T and later readings count from the new start; never write the old final value as the reading at T (double-counts, then rejects every new-register reading). Deleting a swap removes its start reading only while it is still the untouched start value; only `Manual` readings are deletable in the UI. **Navigation conventions:** the meter page is the per-meter hub (header actions: primary entry by mode, "Record event" menu, Edit via the shared `Shared/MeterEditor.razor`, which also owns tank setup); link into it with `MeterLinks` (`/meters/{id}?tab=…&action=…`, action consumed once after the interactive render and dropped from the address); the app-bar "Find a meter" dialog offers the same quick entry; `NavState` tells the per-circuit nav to reload energy types after admin edits. A source that lacks a usable connector detours through `/admin/connectors?new=…|edit=…&meter=…` and comes back to that source dialog with the connector picked and everything typed restored (the page saves the open dialog to the circuit-scoped `DraftStore` on dispose) (`MeterLinks.Source`/`NewConnector`/`EditConnector`; the way back is a meter id, never a URL, so it cannot redirect off-site); the connector list shows which meters use each connector. The meter editor owns the meter's own cost-category memberships (type-level ones are only named), import batches list the meters/categories they wrote to, and the dashboard's empty cost panel names the first missing setup step (`DashboardService.GetCostSetupAsync` → `CostSetup.FirstGap`). **UI language** (M7's last item) is now English + German end to end — see *Localization* below. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo. +**Status: implemented (M0–M7) + the dashboard/analysis rework (next release 0.4.0).** +- **Size:** five projects, ~2,480 tests (Core 1,733, Integration 746), working Docker deploy. +- **Docs:** + - `docs/SDD.md` is the design reference. It marks in place every section the system now deviates from (list: note D-58). Its milestone map (§12) matches the git history (M0…M7). + - The rework's work order is `docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md`. Its decisions are D-01…D-58 and amendments A-01…A-39 in `docs/ANALYSIS_IMPLEMENTATION_NOTE.md`. **Read that note before touching analysis, costing, rollups, virtual meters or the analysis pages.** + - Outcome and acceptance evidence: `docs/ANALYSIS_REPORT.md`. User-facing changes: `docs/RELEASE_NOTES.md`. +- **Pages:** + - Overview `/`, Analysis `/trends`, Meters `/meters`, the meter hub `/meters/{id}`, energy type `/energy/{id}`, Solar `/solar`, Tanks & consumables `/consumables`, import `/import` + `/import/wizard`, Configuration `/admin/*`. See *Analysis & navigation*. + - Their read models live in `Infrastructure/Dashboard`: `DashboardService.GetOverviewAsync`, `SolarService`, `ConsumableService` (+ pure `TankLevels`), `MeterDetailService` (the paged record tabs), `FlowService`. All of them sit on the two shared readers. + - PV, grid and load meters are found by mode and by *effective* role (`MeterRoleRules.Effective`, A-07). The role is stored as the `role` token in `Meter.Meta` (`MeterRoles`/`MeterMeta`). It is saved only through `MeterRoleAssignment`, which keeps a role unique per type among meters in service and names the meter it moved from. Nothing is found by name. +- **Admin write-CRUD** (SDD §8.7): MudBlazor inline-dialog pages for energy type *definitions*, meters, a meter's ingest sources (meter Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`; secrets as an env-var reference or typed in and encrypted at rest). + - The shared `Shared/MeterEditor.razor`: + - recomputes the meter when mode, baseline, install date, unit, role or tank change (`RecomputeNeeded`); + - owns the totals override, tank setup and the meter's own cost-category memberships (type-level ones are only named); + - for virtual meters, holds the calculation editor (`Shared/MeterEditing/`, `App/MeterEditing/`, preview through `MeterDraftAnalysis`). + - Deleting a meter or type goes through `EntityDeletion`, which also removes its scoped tariffs. The delete dialog names the virtual meters that read the meter (`VirtualMeterService`, D-33). + - `/admin/settings` is read-only: the effective config (env-driven, not DB-stored) plus the analysis data state: revision, zone, meters pending a rebuild, and raw retention "Not enforced". +- **Manual readings:** the header's "Add reading" (also on the Readings tab and as a quick entry) opens `MeterPage/ManualReadingDialog.razor`. + - It is touch-first: prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter and a live parsed-value + delta-since-last readout. The decrease guard is surfaced before saving. + - Its verdict comes from its own queries for the entered time (latest reading, the previous reading on the normalizer's timeline, a swap/reset that explains a decrease, the reading at T with its flags), never from a page of rows (D-50). + - It saves through `IngestionService.IngestByMeterAsync(quality: Manual)`, so the reading is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest. + - The dialog deliberately reserves fixed space for its verdict line: anything that reflows moves the keys out from under the user's thumb mid-entry. +- **Home Assistant reading:** an HA connector (`BaseUrl` + `TokenEnv`) plus an HA source (entity id) drives `HomeAssistantWorker`'s REST poll. + - With the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`), `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time. The poll worker skips WS endpoints, so each endpoint is served once. + - `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic. `HaConnectionTester` powers the connector "Test connection" button. +- **Meter topology & flow:** a `MeterLink` is a directed `from→to` edge: the downstream meter is a *subsection* of the upstream one, and several parents are allowed. + - Links are **topology only** and never define or change a virtual meter's calculation (D-25). + - Edit them under Energy type → Flow → "Manage connections". `MeterLinkService` checks for cycles, other types, duplicates and legacy virtual meters inside a transaction with `LOCK TABLE meter_link`; it is built inside the dialog, not registered in DI. A physical meter's upstream field in the editor also edits them. + - The Flow tab draws a hand-rolled SVG **Sankey** (`SankeyChart.razor`; ApexCharts has no Sankey type) from `FlowService.FromResultAsync`, over the same reader result as the page: + - Each node has its canonical period value. An edge carries the downstream meter's value, split proportionally across parents (marked estimated) and capped at the parent. The unaccounted remainder becomes "Other". + - A pure-sum virtual meter is drawn with its calculation inputs, marked calculated. Other virtual meters and meters in another unit appear only in the flow table. + - A node without data is named as such, never shown as a fake 0. +- **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch`. The `/import` page lists batches, with one-click revert and the meters/categories each batch wrote to. +- **`instant_rate`:** `InstantRateNormalizer` integrates the rate over time (trapezoidal). +- **Meter events from the UI** (swap, counter reset, tank level, delivery, note) go through `MeterEventService` (`Infrastructure/Ingestion`), never ad-hoc inserts. + - `MeterEventRules.RecordableFor(mode)` (Core) decides which events a mode offers. There is no `Correction`: nothing reads it. + - `Validate` gives the dialog the same verdict the save reaches. Every record or delete recomputes the meter in one transaction. + - A swap/reset is stored as the event at T **plus a manual reading of the new register's start value at exactly T** (flagged `MeterSwap`/`CounterReset`). The boundary window is `(prevReading, reading]`, so this books the old tail at T and later readings count from the new start. Never write the old final value as the reading at T: it double-counts, then rejects every new-register reading. + - Deleting a swap removes its start reading only while it is still the untouched start value. Only `Manual` readings are deletable in the UI. +- **Navigation conventions:** + - The meter page is the per-meter hub. Header actions: primary entry by mode, the "Record event" menu, Edit. + - Link into it with `MeterLinks` (`/meters/{id}?tab=…&action=…`). The action is consumed once after the interactive render and dropped from the address. + - The app-bar "Find a meter" dialog opens a meter's Analysis tab with the current period, plus the same quick entry. + - `NavState.NotifyEnergyTypesChanged()` / `NotifyMetersChanged()` tell the per-circuit nav to reload. Raise them from anything that creates or deletes types, meters or tanks. + - **Connector detour:** a source that lacks a usable connector detours through `/admin/connectors?new=…|edit=…&meter=…` (`MeterLinks.Source`/`NewConnector`/`EditConnector`). It comes back to that source dialog with the connector picked and everything typed restored: the page saves the open dialog to the circuit-scoped `DraftStore` on dispose, and only Cancel discards it. The way back is a meter id, never a URL, so it cannot redirect off-site. + - The Sources tab links each connector to its editor, the source dialog has "Edit connector", and the connector list shows which meters use each connector. + - The Overview shows small setup notes from `DashboardService.GetCostSetupAsync` → `CostSetup.FirstGap` (no meters/tariffs next to the cost card, no categories/members under the composition). They are never a prerequisite for seeing quantities. +- **UI language:** English + German end to end. See *Localization*. +- **Demo:** set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo: meters 1–5 Strom (Haus, Netz, Auto, Solar 1, Solar 2), 6 Wasser, 7 Öltank + 8 Brenner (Heizöl), 9 Summe Solar (virtual `m4 + m5`, generation, not costed). ## Source of truth `docs/SDD.md` is the authoritative spec and build brief — read it before implementing anything. Key protocol from §0 that governs all work here: - **Build strictly in milestone order (§12, M0→M7).** Each milestone is independently runnable and testable; do not start Mn+1 until Mn's tests pass. -- **The four CSVs in `sampledata/` are golden fixtures.** Every parsing / consumption / cost rule must reconcile against them (§13). **If a computed number disagrees with the spreadsheet, the spreadsheet wins** unless the discrepancy is a deliberately documented correctness fix. -- When a design decision is ambiguous, check §14 (open questions): if listed, take the stated default and flag it; if not listed, ask before guessing. -- Keep the **domain layer free of infrastructure concerns** (the domain model and DB schema are UI-agnostic by design). +- **The four CSVs in `sampledata/` are golden fixtures.** Every parsing / consumption / cost rule must reconcile against them (§13). **If a computed number disagrees with the spreadsheet, the spreadsheet wins** unless the discrepancy is a deliberately documented correctness fix. The seeded bill is pinned to the sheet's `Jahreskosten` (`SeededBillTests`, D-44). +- When a design decision is ambiguous, check §14 (open questions): if listed, take the stated default and flag it; if not listed, ask before guessing. For analysis, costing and page behaviour, check the implementation note (D-nn/A-nn) first; a new decision gets a new `A-nn` there. +- Keep the **domain layer free of infrastructure concerns** (the domain model and DB schema are UI-agnostic by design). Pure analysis rules belong in `Core/Analysis`, reads in `Infrastructure/Analysis`/`Costing`, and presentation in `App`. ## Committed tech stack (do not re-litigate; see SDD §4.1) -.NET (current LTS — .NET 10, .NET 8 acceptable), C# · ASP.NET Core + **Blazor Server** · **MudBlazor** components · **ApexCharts** (Blazor-ApexCharts) · **MQTTnet** · **PostgreSQL + TimescaleDB** · **EF Core (Npgsql)** for schema/CRUD + **Dapper** for hot-path time-series reads · `BackgroundService` hosted services for ingestion/aggregation · **xUnit + Testcontainers** (Timescale image) · Docker Compose + GHCR. +.NET (current LTS — .NET 10, .NET 8 acceptable), C# · ASP.NET Core + **Blazor Server** · **MudBlazor** components · **ApexCharts** (Blazor-ApexCharts) · **MQTTnet** · **PostgreSQL + TimescaleDB** · **EF Core (Npgsql)** for schema/CRUD + **Dapper** for hot-path time-series reads · `BackgroundService` hosted services for ingestion · **xUnit + Testcontainers** (Timescale image) · Docker Compose + GHCR. No bUnit/Playwright: browser checks are manual/CDP-scripted. ## Project layout ``` -/src/Core domain entities + enums; pure Normalization engine (mode strategies, - expression evaluator); Parsing (German dialect); Costing (TariffResolver) -/src/Infrastructure MeterVaultDbContext + migrations (relational + raw-SQL Timescale); - Import (CsvImporter, profiles, ImportService), Ingestion (MQTT/HA workers, - IngestionService), Normalization service, Costing/Dashboard/Backup services -/src/App ASP.NET Core host: Blazor Server UI (Components/), REST API (Api/), hosted - workers, Program.cs (Serilog, migrate+seed on startup, /healthz) -/tests/Core.Tests unit (no Docker): parsers, normalizers, swap→12, tariff resolver -/tests/Integration.Tests Testcontainers (Timescale): reconciliation vs the 4 fixtures, - import commit/revert, ingestion, cost, CAgg refresh, API, export, render +/src/Core domain entities + enums; pure Normalization engine (mode strategies); Parsing (German + dialect); Costing (legacy TariffResolver) +/src/Core/Analysis pure analysis rules: Time (PeriodResolver, BucketPlanner, ComparisonResolver, Change), + Coverage (runs, evaluator, matched coverage, provenance), Rollups, Quantities (units, + normalized quantity, roles, tariff units), Totals (policy, category cover), Virtual + (formula parser, validator, dependency graph, evaluator, legacy derivation), + Costing (CostCalculator, TariffBook, CostAmount) +/src/Infrastructure MeterVaultDbContext + migrations (relational + raw-SQL Timescale); Import (CsvImporter, + profiles, ImportService); Ingestion (MQTT/HA workers, IngestionService, MeterEventService); + Normalization (NormalizationService + AnalysisDataWriter, NormalizationUpgrade); + Analysis (AnalysisReader, AnalysisCatalog, AnalysisQueries, VirtualDefinitionUpgrade, + MeterDraftAnalysis); Costing (CostReader, BillRun; CostService = legacy API adapter); + Dashboard (page read models); Backup (JSON export/import) +/src/App ASP.NET Core host: Program.cs (Serilog, migrate+seed+upgrades on startup, /healthz), + REST API (Api/), Components/ (Pages/, Shared/, Shared/Analysis/), Analysis/ (URL contract, + chart/table/attention models, CSV export), AnalysisPage/, Energy/, MeterDetails/, + MeterEditing/, TariffEditing/, Theme/, Localization/, link helpers (MeterLinks, + AnalysisLinks, TariffLinks), InstanceClock, InstanceCurrency +/tests/Core.Tests unit (no Docker): parsers, normalizers, swap→12, Analysis/ (periods, DST, coverage, + totals, virtual formulas, cost calculator) +/tests/Integration.Tests Testcontainers (Timescale): reconciliation vs the 4 fixtures, import commit/revert, + ingestion, rollups, reader, cost engine, seeded bill, API contracts, export, render; + pure UI-model tests (Analysis/, MeterPage/, Overview/, Editor/, Specialized/); + Performance/ (trait Category=Performance, opt-in) /deploy Dockerfile, docker-compose.yml (app + timescaledb), build-and-push.ps1, unraid-template.xml ``` Central package versions live in `Directory.Packages.props`; shared build/style in -`Directory.Build.props` + `.editorconfig`. Snake_case table/column mapping via +`Directory.Build.props` + `.editorconfig` (`TreatWarningsAsErrors`). Snake_case table/column mapping via `UseSnakeCaseNamingConvention`. EF migrations are exempt from code-style enforcement (see `.editorconfig`). ## Commands @@ -48,14 +110,19 @@ dotnet build # build the solution dotnet test # all tests (Integration.Tests needs Docker for Testcontainers) dotnet test tests/Core.Tests # unit tests only (no Docker needed) dotnet test tests/Integration.Tests --filter "FullyQualifiedName~Reconciliation" # one class/area +dotnet test tests/Integration.Tests --filter "FullyQualifiedName~StringResource" # both resx files complete +$env:METERVAULT_PERF='1'; dotnet test tests/Integration.Tests -c Release --filter "FullyQualifiedName~Performance.ReaderTimingTests" # ~10 min dotnet ef migrations add -p src/Infrastructure -s src/App -o Persistence/Migrations dotnet run --project src/App # run app + workers locally (needs a Timescale DB) docker compose -f deploy/docker-compose.yml up # app + TimescaleDB together ``` -**Timescale-in-EF gotchas** (already handled — follow the pattern): hypertable/CAgg DDL lives in -raw-SQL migrations; continuous-aggregate creation + policies use `migrationBuilder.Sql(..., suppressTransaction: true)`, one statement each; CAgg policy `end_offset` must be ≥ one bucket. Tests -pause the compression job (historical fixture data would otherwise deadlock imports). +**Timescale-in-EF gotchas** (already handled — follow the pattern): hypertable DDL lives in raw-SQL migrations, one +statement each with `migrationBuilder.Sql(..., suppressTransaction: true)` where Timescale needs it. The old continuous +aggregates (and their "`end_offset` ≥ one bucket" rule) only matter for the historical migrations: the `AnalysisRollups` +migration dropped them (D-17) and purged stored consumption of virtual meters. Tests pause the compression job (historical +fixture data would otherwise deadlock imports). An UPDATE of rows in compressed chunks needs TimescaleDB's decompression +cap lifted for that statement (see `NormalizationUpgrade`). ## Core architecture (the part that spans multiple files) @@ -64,27 +131,158 @@ pause the compression job (historical fixture data would otherwise deadlock impo ``` sources (Tasmota/HA/MQTT/manual/CSV) → Ingestion workers write raw `reading` rows (immutable audit truth) - → Normalization derives append-only `consumption` (deltas in base unit) - → TimescaleDB continuous aggregates roll consumption to hourly/daily/monthly/yearly - → Cost engine joins aggregates with time-ranged `tariff` - → Blazor dashboard + REST API read aggregates + cost views + → NormalizationService.RecomputeMeterAsync derives append-only `consumption` (deltas in base unit, each row + with its source interval) and, in the same transaction and by diff, the per-meter rollups by local day + and month (`consumption_rollup`, `consumption_rollup_month`), coverage runs (`meter_coverage`) and + `meter_rollup_state` (revision, zone, normalized unit, kind) — AnalysisDataWriter, D-10 – D-16 + → AnalysisReader: quantities of physical meters (rollups + ≤ 2 edge days of `consumption`), virtual meters + (evaluated on read from their sources) and per-type measures, for one resolved period and bucket plan + → CostReader: the bill (BillRun → Core CostCalculator), month by month from time-ranged `tariff` + → Blazor pages, REST API (/api/v1) and CSV export (/export/analysis.csv) read only those two readers ``` **Invariants that shape everything:** -- **Raw `reading` is immutable audit truth.** Everything derived (consumption, cost, balances, forecasts) is computed on top and must be reproducible. Never mutate readings to fix a derived number. Live ingestion recomputes the meter inline (`IngestionService.RenormalizeAsync`) — without it, polled readings never become consumption. -- **Consumption is attributed to the months it accrued in** (`GapAttribution`, SDD §7.1). A plain increase whose interval crosses a *local* month boundary (instance timezone) is divided at those boundaries by elapsed time, each share stamped inside its month (the closing reading keeps its own row when it lies in that month), marked `Estimated`. Imported monthly-table rows are month-end snapshots, marked by the **importer** with `ReadingFlags.MonthLabel` when the date cell named a month (`IsMonthLabel` = the flag; never infer it from a midnight-on-the-1st stamp — a day-dated "01.08.2026" is an instant). A manual correction keeps the flag; a live/API value or a swap start reading written onto that instant clears it (`IngestionService.UpsertAsync`). `EffectiveTime` reads a label as the end of its month; **`ReadingTimeline`** (Core) is the one ordering — effective time, then stamp — used by `CounterNormalizerBase` and `RuntimeCounterNormalizer`, and via `RegisterNeighbours` by the ingestion decrease guard and `MeterEventService.GetContextAsync`, so none of them disagrees about which reading is "previous". Consecutive rows span exactly one month and book unchanged (the golden fixtures reconcile), a live reading after the last imported row counts from that month's end, and a sheet imported after live readings does not double-count. A swap/reset stamped exactly at a label sits at the start of that label's local month (`ReadingTimeline.BoundaryTime`), and `RegisterBoundary.Advance` never counts a start value above the reading. `StampTime` keeps a label's row inside its own local month (zones behind UTC; also used by `DirectDeltaNormalizer`); `NormalizationEngine` coalesces any rows that still share a (time, kind) key; `GapAttribution.LocalMidnight` verifies its answer so contradictory zone data cannot stall the month walk. `GapSplittingIsInertOnFixturesTests` pins that no fixture interval is divided, in UTC or Berlin. Swaps/resets/decreases are never divided. **Readers bucket in the configured `MeterVault__TimeZone`** (`CostService`, `SolarService`, `ConsumableService`, `MeterPeriodService` take `@tz`) and turn requested dates into instants with `InstanceTimeZone.StartOf` (local midnight, not UTC midnight — `DashboardService`, `FlowService`, `EnergyView` too); a hard-coded zone or UTC-midnight range would re-file the divided shares. `Program` post-configures the zone id to its IANA form (`InstanceTimeZone.Canonical`) and logs an error when .NET or PostgreSQL does not know it. Stored consumption records `normalization_revision` and `normalization_zone` in `app_setting`; `NormalizationUpgrade` (startup migration step) first flags month rows of pre-revision-2 imports (reference profiles by name, wizard `MonthName` batches, and wizard `Auto` batches whose every row sits on the 1st across ≥2 months — logged per batch), lifting TimescaleDB's decompression cap for that one UPDATE; if flagging fails nothing is rebuilt or recorded. It then rebuilds every non-virtual meter (virtual ones are computed on read) when revision or zone differs, per meter in its own transaction; a failing meter is logged, kept in `normalization_pending` and retried next start — the upgrade must never crash startup. Bump `CurrentRevision` whenever the engine books existing readings differently. -- **Dashboards and charts read aggregates only — never scan `reading`.** This is what makes 1000 meters × 50 years feasible (§5.5). Raw is kept for a bounded window (default 3y); `consumption` + aggregates are the long-term source of truth. -- **`meter.mode` (measurement mode) is the central abstraction** for how raw readings become consumption (SDD §5.2): `cumulative_counter`, `generation_counter`, `runtime_counter` (Δhours × rate), `consumable_balance` (tank: deliveries − usage + forecast), `direct_delta`, `instant_rate`, `virtual` (expression over other meters). New ingestion/normalization logic dispatches on mode. -- **Nothing domain-specific is hardcoded.** Energy types are data. Cost **categories are decoupled from energy types** (Heizung may be oil today, heat-pump tomorrow). PV self-consumption/savings/net are **virtual meters** with user-defined expressions, not special-cased code. Tariffs are time-ranged (price history), scoped global / per-type / per-meter. +- **Raw `reading` is immutable audit truth.** Everything derived (consumption, rollups, cost, balances, forecasts) is computed on top and must be reproducible. Never mutate readings to fix a derived number. Every write path recomputes the meter inline: live ingestion (`IngestionService.RenormalizeAsync`), import and revert, events, manual readings and deletions, meter edits, the events API. Without it, readings never become consumption and the rollups go stale. There is no cache and no refresh job. +- **Consumption is attributed to the months it accrued in** (`GapAttribution`, SDD §7.1). + - **Division:** a plain increase whose interval crosses a *local* month boundary (instance timezone) is divided at those boundaries by elapsed time. Each share is stamped inside its month (the closing reading keeps its own row when it lies in that month) and marked `Estimated`. Only cumulative/generation counters divide. Tank, runtime, direct-delta and instant-rate intervals are booked whole, so months they straddle read `Unresolved` (A-16). Swaps, resets and decreases are never divided. + - **Month labels:** imported monthly-table rows are month-end snapshots. The **importer** marks them `ReadingFlags.MonthLabel` when the date cell named a month (`IsMonthLabel` = the flag). Never infer it from a midnight-on-the-1st stamp: a day-dated "01.08.2026" is an instant. A manual correction keeps the flag; a live/API value or a swap start reading written onto that instant clears it (`IngestionService.UpsertAsync`). + - **One ordering:** `EffectiveTime` reads a label as the end of its month. **`ReadingTimeline`** (Core) orders by effective time, then stamp. `CounterNormalizerBase` and `RuntimeCounterNormalizer` use it, and so do the ingestion decrease guard, `MeterEventService.GetContextAsync` and the manual-entry dialog (via `RegisterNeighbours`), so none of them disagrees about which reading is "previous". + - **Consequences:** consecutive rows span exactly one month and book unchanged, so the golden fixtures reconcile. A live reading after the last imported row counts from that month's end, and a sheet imported after live readings does not double-count. + - **Boundaries:** a swap/reset stamped exactly at a label sits at the start of that label's local month (`ReadingTimeline.BoundaryTime`), and `RegisterBoundary.Advance` never counts a start value above the reading. `StampTime` keeps a label's row inside its own local month (zones behind UTC; also used by `DirectDeltaNormalizer`). A non-label row closing exactly at a local midnight is stamped 1 s earlier, inside the day it closes (D-11). + - **Guards:** `NormalizationEngine` coalesces rows that still share a (time, kind) key. `GapAttribution.LocalMidnight` verifies its answer, so contradictory zone data cannot stall the month walk. `GapSplittingIsInertOnFixturesTests` pins that no fixture interval is divided, in UTC or Berlin. +- **Everything buckets in the configured `MeterVault__TimeZone`**: normalization, rollups, `AnalysisReader`, `CostReader`, the export. Requested dates become instants through `PeriodResolver` / `InstanceTimeZone.StartOf` (local midnight, never UTC midnight). A hard-coded zone or a UTC-midnight range would re-file the divided shares. `Program` post-configures the zone id to its IANA form (`InstanceTimeZone.Canonical`) and logs an error when .NET or PostgreSQL does not know it. +- **Startup upgrades, in order; none may crash startup:** + 1. Migrations, then the seed. + 2. `VirtualDefinitionUpgrade` (D-28): an expression-less virtual meter whose same-type links imply an unambiguous sum gets it stored. Idempotent and logged; anything else is "needs configuration". + 3. `NormalizationUpgrade`. `app_setting` records `normalization_revision` (now **3**) and `normalization_zone`. + - Before revision 2 it first flags the month rows of older imports: reference profiles by name, wizard `MonthName` batches, and wizard `Auto` batches whose every row sits on the 1st across ≥ 2 months (logged per batch). It lifts the decompression cap for that one UPDATE. If flagging fails, nothing is rebuilt or recorded. + - Then it rebuilds consumption + rollups + coverage + state of **every** meter when the revision or zone differs. Otherwise it rebuilds only meters whose `meter_rollup_state` is missing or outdated, plus `normalization_pending`. Virtual meters are purged, since they store nothing. Each meter runs in its own transaction. + - A meter whose oldest consumption predates its oldest reading or event is skipped and logged instead of truncating history. A failing meter is logged, kept pending and retried at the next start. + - Until its rebuild runs, a meter reads as `Pending` ("analysis being prepared"), never "no data". **Bump `CurrentRevision` whenever the engine books existing readings differently.** The rebuild runs before the web server listens; roughly 0.1 s per monthly meter and ~1.4 s per meter with a year of hourly data (it grows with the reading count). +- **Dashboards and charts read rollups only — never `reading`.** This is what makes 1000 meters × 50 years feasible (§5.5). `consumption` + rollups are the analytical history. `reading` is read by the paged Readings tab, the manual-entry checks and the freshness query (latest 20 reading times per meter, D-18). **Raw retention is not enforced** (D-57, a documented blocker): every recompute rebuilds a meter from its readings, so dropping old readings would destroy history. `/admin/settings` and the Readings tab say so. +- **`meter.mode` (measurement mode) is the central abstraction** for how raw readings become consumption (SDD §5.2): `cumulative_counter`, `generation_counter`, `runtime_counter` (Δhours × rate), `consumable_balance` (tank: deliveries − usage + forecast), `direct_delta`, `instant_rate`, `virtual` (a formula over other meters, **evaluated on read, never stored**: D-27, SDD §14.1). New ingestion/normalization logic dispatches on mode. `NormalizedQuantity` (D-20) gives each meter's analysis (kind, unit): + - runtime: `h`, or the tank unit with a fixed rate; + - instant rate: the rate unit without `/h` (W → Wh); + - tank: the tank unit; + - virtual: its declared result unit. + `Units` is the only unit normalizer (m3 = m³). Raw units appear only on the Readings tab. +- **Nothing domain-specific is hardcoded.** Energy types are data. Cost **categories are decoupled from energy types** (Heizung may be oil today, heat-pump tomorrow). PV self-consumption/net are **virtual meters** with user-defined formulas, not special-cased code. Formulas are quantities only: prices are not part of expressions, and a virtual meter's cost follows its `costRule` (D-39). Tariffs are time-ranged (price history), scoped global / per-type / per-meter. -**Timescale vs EF split (SDD §5.3):** EF Core migrations own the relational tables. **Timescale-specific DDL — `create_hypertable`, compression policies, continuous aggregates, retention — is not expressible via EF's model builder and must live in raw-SQL migrations.** `reading` and `consumption` are hypertables. +**Timescale vs EF split (SDD §5.3):** EF Core migrations own the relational tables. The rollup, coverage and state tables are plain tables with a cascading FK to `meter` (D-12). **Timescale-specific DDL — `create_hypertable`, compression policies — is not expressible via EF's model builder and must live in raw-SQL migrations.** `reading` and `consumption` are hypertables. -**Time & DST (SDD §10):** store UTC everywhere; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`. +**Time & DST (SDD §10):** +- Store UTC everywhere. Bucket and display in the configured instance timezone (`MeterVault__TimeZone`, default `Europe/Berlin`); never hard-code Berlin. +- A period resolves once per request into a local inclusive date range (display) and a **half-open** UTC range `[from, to)` (every query). `to` is the local midnight after the end date, or the captured "now" for to-date periods. +- Days are local midnights (a DST day has 23 or 25 hours), weeks start Monday, and months and years are local. +- "Now" comes from the registered `TimeProvider`: pages read it once through `InstanceClock` (`Now`, `Today`). Services never read the clock (D-01). Tests use `FixedTimeProvider`. +- Rows whose interval closes after now are never actuals. They are reported as "recorded after now" (D-04, A-04, A-05, A-14, A-20). -**In-app update (`UpdateRunner`):** the dashboard shows a banner when a newer tag exists (`UpdateCheckService`, cached, never blocks a render). Triggering an update is **off by default**; `MeterVault__AllowInAppUpdate` is the *only* gate — no API key, by explicit owner decision. With it on, anything that can reach the app can trigger a rebuild+restart as root (realistically a DoS, since the build comes from the owner's own repo; RCE if that repo is compromised). The REST endpoint additionally requires an `X-MeterVault-Update` header — a CSRF guard, not auth, so a foreign page cannot drive it via a LAN browser. Launches detached via `systemd-run` because the update restarts the service. Treat any change here as security-critical; `UpdateRunnerTests` pins that the flag defaults off and that API keys alone don't enable it. +## Analysis layer (Core/Analysis + Infrastructure/Analysis + Infrastructure/Costing) -**Localization (SDD §12, M7 — en + de):** UI strings live in `src/App/Localization/Strings.resx` (neutral = English) and `Strings.de.resx`. MSBuild generates a **strongly-typed** `Strings` class from the neutral resx (see the `EmbeddedResource` block in `MeterVault.App.csproj`), aliased as `S` in `_Imports.razor` — so components write `@S.Common_Save`, never a string key, and a stale key is a build error. `Loc.F(S.Key, args)` formats the `{0}` ones. **Adding a string means editing both resx files**: `StringResourceTests` fails the build on a missing or blank translation, a placeholder mismatch, an orphan, or a key nothing references — resource fallback would otherwise hide a half-translated release. Domain **enums stay bare identifiers** (they are persisted as text and appear in the REST API); `DisplayNames.Display()` is the single place that decides how each value is *spoken*, and `EnumDisplayNameTests` fails if a value has no wording. Anything from the database (meter names, energy-type display names, category names) is user data and is **never** translated. Language is per-request: the cookie the `/culture/set` endpoint writes, else `Accept-Language`, else `MeterVault__Locale` (default `en`). Switching must be a **full reload** (`forceLoad`) — a Blazor Server circuit is fixed to the culture of the request that opened it. `Format.*` formats against `CurrentCulture`, so numbers and month labels follow the reader; the CSV importer's de-DE parsing is unrelated and unchanged, because that dialect belongs to the files, not the reader. +- **Two readers are the only read path.** Pages, `/api/v1`, the CSV export, Solar, Consumables, Flow and the Overview all read figures through `AnalysisReader` (quantities) and `CostReader` (money). Never add a figure that queries `consumption` or `reading` directly. + - **`AnalysisReader`:** one request = scope + resolved period + `BucketPlan` + comparison. + - It loads `AnalysisCatalog` once: meters, tanks, links, rollup states, validated virtual definitions and the totals classification. + - It expands virtual dependencies in memory, then reads each table once for all physical meters involved (`AnalysisQueries`, Dapper): month rollups for month/year buckets, day rollups for day/week buckets, at most two partial edge days from `consumption`, coverage, freshness. + - The 400-point and 6-series limits are checked before any SQL runs. + - **`CostReader`:** runs `BillRun` → Core `CostCalculator` with a `TariffBook`: one catalog, tariff and manual-cost load, plus one reader pass for every meter any figure prices. + - `CostService` is only the adapter behind `/api/v1/consumption|cost`. `DashboardService.GetMonthlyTrendAsync`/`GetCategoryBreakdownAsync`/`GetCategoryDifferenceAsync` and `FlowService.GetFlowAsync(DateOnly…)` are legacy entry points used only by tests. +- **Status, never a silent 0 (D-14):** + - Every bucket has a `BucketStatus`: + - `Available`: covered; a zero is a true zero. + - `Partial`: only part of the bucket is covered. + - `Missing`: nothing covers it. + - `Unresolved`: data only at a coarser resolution than the bucket. + - `Invalid`: a calculation failed. + - `Pending`: the meter is being rebuilt. + - It is derived from coverage runs and their resolution class (≤ 1 h, ≤ 1 day, ≤ 7 days, ≤ 1 month, coarser; A-02, A-03), never from the amount. + - Separate dimensions: `Provenance` flags (measured, manual, imported, estimated, derived, opening balance), `ValueIssue` (why a value is not plain) and freshness (stale live source vs historical import, D-18). + - Outside `[InstalledAt, RetiredAt]` a meter is a known zero (D-24). + - A first reading with unknown start is an opening balance: partial, excluded from comparisons, with "Set install date" offered (A-01). +- **Virtual meters (D-25 – D-33, A-08, A-12, A-15):** + - The definition is the `Meter.Meta` keys `expression` (`m` references), `referencedMeterIds` (derived and rewritten on save), `resultKind` (consumption, generation, net, indicator), `resultUnit` and `costRule` (none, sourceCosts, ownQuantity), written through `VirtualDefinitionJson`. + - `FormulaParser` builds an AST: `+ − * /`, parentheses, numbers; at most 2,000 characters and depth 64. An unknown identifier is an error, never 0. + - `VirtualValidator` + `DependencyGraph` check on save and on read: syntax, unknown/self references, loops (with their path), kind/unit rules. `+`/`−` need the same kind and unit or a declared net; meter × or ÷ meter is an indicator, which is non-additive and never totalled or costed. + - `VirtualEvaluator`, fed per-day source coverage from `CoverageEvaluator` (A-12): + - strict: a missing source → `Missing` with the source named; an observed zero is valid; + - a non-finite result or a loop → `Invalid` with the reason and dependency path; + - the period total is the formula over the joint coverage; non-linear formulas are marked non-additive (ratio of totals); + - results carry every source's series (the page's "source contributions"). + - Legacy meters without an expression are read as their implied link sum with status `Legacy` until `VirtualDefinitionUpgrade` stores it (`LegacyVirtualDerivation`). + - The editor previews unsaved definitions through `MeterDraftAnalysis`. Export/import remaps meter ids inside definitions and carries `meter_link` (D-32). +- **Totals (D-22, D-23):** `TotalsPolicy`/`TotalsGraph` classify each type's meters into the measures Use, GridImport, Export, Generation and Runtime. + - **Use** is the `total_load` meter, else the consumption roots. + - Links out of supply meters (grid, generation, generation-kind virtual) are supply edges. Other links make the target a **breakdown** of its parent. + - Measures are never added across units. Virtual meters are *analysis views*: never added on top of their sources. + - `Meta.totals = auto|always|never` overrides this. `always` lets a virtual meter replace its sources in totals and bill, and is refused (naming the meter) when an ancestor or dependent already counts. + - Seeded result: Strom use = Haus, breakdown = Auto, grid import = Netz, generation = Solar 1 + Solar 2, Summe Solar analysis-only. +- **The bill (D-34 – D-43, A-15 – A-19, A-21, A-22, A-26):** + - **What is billed:** per type, `grid_import` meters if any, else the use meters; separately priced subsections at their own price, taken out of the parent (D-35, A-19). The feed-in credit applies only to `grid_export` meters. Generation, runtime and virtual views are never billed. Months with use but no grid meter in service are unavailable (A-17). + - **Pricing:** the price of the **15th of each local month** (D-36); every bucket is cut into local months, so the bucket size never changes a total. An interval longer than a month is priced whole only when its months share a price (A-16). + - **Tariff units** must fit the meter's normalized unit and the instance currency, else `UnitMismatch` (D-37). Bonus, Discount and Tax are **not applied** (D-57). + - **Missing prices:** `NotPriced` (no tariff at any date: an attention item, never a partial total) vs `PriceGap` (a hole in a priced history) vs a valid explicit **zero**. A bucket with nothing booked is unknown ("No data"), never "Priced" (A-26). + - **Standing charges** accrue per local day over the scope's service period, **once per scope**. Type and global charges are their own rows; meter fees stay on their meter (D-40, A-18). + - **Manual costs** are booked once, in full, on their `PeriodStart` day (D-41). + - **Categories** price the non-overlapping cover of their members (`CategoryCover`). The disjoint categories, Uncategorized and the standing-charge rows form the **composition**, which reconciles to the bill. Overlapping categories are "views", never summed; the donut appears only for non-negative slices (D-42). A category whose members price nothing says so (A-22). + - **Virtual costs** follow the named `costRule` (D-39, A-15): `sourceCosts` for pure sums, `ownQuantity` for linear formulas, `none` otherwise and for generation sums (so Summe Solar is not costed). +- **Changes (D-06 – D-09, A-10, A-13, A-23):** + - `ComparisonResolver` shifts in calendar units (MTD/YTD at the same elapsed wall time) and pairs buckets by index (`PairBuckets`). `MatchedCoverage` measures a change only over what both periods cover. + - `Change.Between` always gives the absolute difference; the percentage is not applicable for a baseline ≤ 0. + - A cost change uses one rule everywhere, `OverviewComparison.Between`: totals if both are complete, else the paired buckets complete on both sides, else "not comparable". + - Projections are separate, labelled and suppressed on thin coverage. + +## Analysis & navigation (App) + +- **URL state is the page state** (`AnalysisQuery`, D-46/D-47): + - Keys: `scope=portfolio|type|category|meter|meters`, `id`/`ids` (≤ 6), `metric=consumption|generation|export|runtime|net|cost|balance`, `period=mtd|last-month|ytd|prev-year|12m|24m|all|custom` with `from`/`to` (local, inclusive), `bucket=auto|day|week|month|year` (≤ 400 points), `compare=none|prev-period|prev-year|year:YYYY`. + - Defaults (`AnalysisDefaults`): Overview `mtd`, history pages `12m` (12 buckets ending with the current partial month), always `prev-year` (A-13). Defaults are **never written**. + - An invalid token falls back to the default with a notice. `all` spans availability (D-19). + - Page-specific keys: `tab`, `action`, the energy page's `view=total|meters`, the Overview's `chart=`. + - Toolbar and tab changes **replace** the history entry (`AnalysisNavigation.Replace`); drill-downs **push** (`AnalysisNavigation.UriFor`, `DrillInto`, `MeterDrill`, D-51, A-24, A-25). + - Build links only with `MeterLinks`, `AnalysisLinks`, `TariffLinks` and `AnalysisNavigation`. They carry the period against the *target* page's defaults. +- **Tabs by key, never by index:** meter tabs `analysis|readings|normalized|events|tariffs|sources|calculation` (`MeterLinks.VisibleTabs`/`ResolveTab`/`PanelIndex`). A virtual meter has no Readings or Normalized tab and has Calculation instead of Sources. Legacy `tab=consumption` → normalized. Energy tabs `overview|history|flow|meters` (`AnalysisLinks.ResolveEnergyTab`/`EnergyTabIndex`). One-shot `action=` stays separate from the analysis reload. +- **Page pattern:** + 1. `AnalysisQuery.Parse(Nav.Uri, Defaults)`. If equal to the last query, stop: a tab change or action drop is not a new analysis. + 2. `AnalysisPeriods.ResolveAsync(query, Clock.Now)`. + 3. Read through a small loader: `MeterAnalysisLoader`, `AnalysisPageLoader`, `EnergyAnalysisLoader`, `DashboardService.GetOverviewAsync`, `SolarService`/`ConsumableService.GetAsync`. + 4. Build the chart/table inputs **inside the load**, since they format eagerly and re-key the chart. + 5. Commit one value through `LoadSequencer.RunAsync` into a `LoadState`. Superseded loads are cancelled and never committed. + 6. Render with `LoadPanel`: initial placeholder, refreshing (old value dimmed), `PanelError` with Retry. + - The initial load happens in `OnParametersSet`, because render tests read prerendered HTML. Subscribe to `Nav.LocationChanged` for query-only changes, and dispose. +- **Shared components** (`Components/Shared/Analysis`): `PageHeader`, `AnalysisBreadcrumbs` (Overview → type → meter, carrying the period), `PeriodToolbar` (presets, custom dates with one Apply, bucket with the refused-size hint, comparison incl. calendar years, metric, Reset, "Export CSV"), `AnalysisChart`, `AnalysisTable`, `MetricCard`, `ChangeChip`, `ValueStatus`, `EmptyPeriodState` (available dates + "Go to latest data"), `PendingState`, `PanelError`, `LoadPanel`, `RefreshIndicator`, `ProjectionNote`, `ComparisonSummary`, `AttentionList`, `SeriesContributions`. + - `AnalysisChart` (ApexCharts) has one axis per unit, nullable points, no smoothing or joining across gaps, and a real zero line for signed data. It follows the theme. + - `AnalysisTable` is the accessible equivalent of every chart and scrolls in its own region. + - Their rules are pure classes in `App/Analysis`: `FigureText` (the status words beside every figure, culprit meters by name), `ChangeDisplay` (metric polarity: more generation is good), `AnalysisChartModel`/`AnalysisChartOptions`, `AnalysisTableModel`, `AttentionItems` (D-53: one targeted action each, e.g. `TariffLinks.For(MissingPrice)` → `/admin/tariffs?scope=&id=&component=&from=&action=new`), `FormulaText`. +- **Missing ≠ zero ≠ not priced** on every page (brief §4.3, A-28): + - A true zero is a number and an outlined bar on the baseline. An unknown bucket is a gap marked "–" in the chart and "—" plus its reason in the table. Qualified values (partial, estimated) are marked "*". + - An empty chart says *why*: no data, only coarser data (naming the resolution, with a button for that interval), or no price. + - The empty-state test is `BucketStatus.Missing`, never a value of 0. +- **CSV export** `GET /export/analysis.csv` (`AnalysisExportEndpoints`, D-55): same keys as the pages, one row per bucket and series, local ISO bounds with offset (end exclusive), invariant numbers, empty cells for unknown values, `status`/`provenance`/`cost`/`cost_status`/`currency`/`comparison_value`. A formula-looking text cell is prefixed with `'`. Bad requests get a 400 with a reason. It is a UI endpoint, so no API key. +- **Theme:** `ThemeState` (scoped) persists light/dark in the `mv-theme` cookie. `App.razor` reads the cookie so prerender and the language switch keep the mode; the default is dark. Charts use a transparent background and palette colours, and are re-keyed on `ThemeState.Changed`. The sidebar's expanded groups use cookie `mv-nav` (`NavGroups`). Both are written through `BrowserPreferences` → `metervault.js` `setPreference`, which whitelists exactly those two names. +- **Currency:** `InstanceCurrency` (`MeterVault__Currency`, default EUR) and `Format.Money`/`MoneySigned`/`CurrencySymbol` for every amount. `Format.Euro` is gone. Never write € in code or resx; user-entered tariff units are data. +- **Formatting:** `Format.Quantity` (unit, "—" when unknown), `Format.PeriodRange`, `Format.BucketLabel` (year only across years; real dates for partial units), `Format.ChangeText`, `Format.MonthYear`. +- **Tests that pin this:** + - URL contract and links: `AnalysisQueryTests`, `AppLinkTests`, `AnalysisNavigationTests`, `LocalTimeEntryTests.Tab_keys_resolve_by_key_and_mode`. + - Loads: `LoadSequencerTests`. + - Chart, table and attention models: `AnalysisChartModelTests`, `AnalysisTableModelTests`, `AttentionItemsTests`. + - Rendered HTML: `AnalysisComponentRenderTests` (framework `HtmlRenderer`), `DashboardRenderTests`, `OverviewPageTests`, `AdminPagesRenderTests`, `MeterSourcesRenderTests`. + - Page logic and loaders: `MeterPageLogicTests`, `MeterAnalysisLoaderTests`, `AnalysisPageLoaderTests`, `EnergyPageTests`, `EnergyTypePageTests`, `OverviewDataTests`, `CostConsistencyTests`. + - Reader and costs: `AnalysisReaderTests`, `AnalysisDataTests`, `CostReaderTests`, `SeededBillTests`. + - Contracts and export: `ApiContractTests`, `AnalysisExportEndpointTests`. + - Shell: `ShellPreferenceTests`. + - Pure Core: `PeriodResolverTests`, `ComparisonResolverTests`, `BucketPlannerTests` (DST/leap/New York on a frozen clock), `TotalsPolicyTests`, `VirtualEvaluatorTests`, `CostCalculator*Tests`. + - Timings (opt-in): `Performance/ReaderTimingTests`. + +**In-app update (`UpdateRunner`):** the Overview shows a banner (in its `PageHeader`, apart from analytical status) when a newer tag exists (`UpdateCheckService`, cached, never blocks a render). Triggering an update is **off by default**; `MeterVault__AllowInAppUpdate` is the *only* gate — no API key, by explicit owner decision. With it on, anything that can reach the app can trigger a rebuild+restart as root (realistically a DoS, since the build comes from the owner's own repo; RCE if that repo is compromised). The REST endpoint additionally requires an `X-MeterVault-Update` header — a CSRF guard, not auth, so a foreign page cannot drive it via a LAN browser. Launches detached via `systemd-run` because the update restarts the service. Treat any change here as security-critical; `UpdateRunnerTests` pins that the flag defaults off and that API keys alone don't enable it. + +**Localization (SDD §12, M7 — en + de):** +- **Resources:** UI strings live in `src/App/Localization/Strings.resx` (neutral = English) and `Strings.de.resx`. MSBuild generates a **strongly-typed** `Strings` class from the neutral resx (see the `EmbeddedResource` block in `MeterVault.App.csproj`), aliased as `S` in `_Imports.razor`. Components write `@S.Common_Save`, never a string key, so a stale key is a build error. `Loc.F(S.Key, args)` formats the `{0}` ones. +- **Adding a string means editing both resx files.** `StringResourceTests` fails the build on a missing or blank translation, a placeholder mismatch, an orphan, or a key nothing references: resource fallback would otherwise hide a half-translated release. +- **Enums:** domain and analysis enums stay bare identifiers (they are persisted as text and appear in the REST API and CSV). `DisplayNames.Display()` (partials `DisplayNames.Analysis.cs`, `DisplayNames.Problems.cs`) is the single place that decides how each value is *spoken*. `EnumDisplayNameTests` fails if a value in `LocalizedEnums` has no wording. +- **MudBlazor's own labels** are translated by `MeterVaultMudLocalizer`. +- **User data** from the database (meter, energy-type and category names) is **never** translated. +- **Language choice:** per request. The cookie the `/culture/set` endpoint writes, else `Accept-Language`, else `MeterVault__Locale` (default `en`). Switching must be a **full reload** (`forceLoad`): a Blazor Server circuit is fixed to the culture of the request that opened it. +- **Formatting:** `Format.*` formats against `CurrentCulture`, so numbers and month labels follow the reader. The CSV importer's de-DE parsing is unrelated and unchanged, because that dialect belongs to the files, not the reader. **Secrets (SDD §6.4):** broker/HA tokens are **never** stored in DB plaintext. Two forms, chosen per connector in the admin UI: a *reference* (`token_env`/`password_env` naming an env var or Docker secret path) resolved at runtime, or *encrypted at rest* (`token_enc`/`password_enc`) via `SecretProtector` over the ASP.NET Core data-protection key ring. Exactly one survives a save; `EndpointSecret.Resolve` is the single resolution path (encrypted wins). The key ring lives outside the app directory (`MeterVault__DataProtectionKeyPath`, default `/var/lib/metervault/keys`) because the LXC updater republishes `/opt/metervault`. `ExportService` drops `*_enc` values — they are bound to the originating key ring. @@ -96,7 +294,7 @@ These CSVs are the German-dialect *Energiebilanz* spreadsheet export and define - **Two date formats:** `Monat YYYY` (German month names, monthly tables) and `DD.MM.YYYY` (event rows). - **Skip inline summary rows** (`Total`, `Heute`, `Seitbeginn Tage`, `Seit YYYY`) and **all-zero future placeholder rows** (e.g. Dec 2026) — do not ingest them. **Negatives are valid** (savings, grid balance). - **Water register swaps mid-series** (…861 → 2 → 15): consumption must stay continuous across the boundary via a `meter_swap` event. -- **Electricity has 5 meters** (Haus, Netz, Auto, Solar 1, Solar 2) plus derived columns. Verified relations to reproduce: `Netz Einsparung = Haus − Netz`, `Ersparnis = Netz Einsparung × €/kWh`, `Kosten = Verbrauchskosten − Ersparnis` — but implement these as **user-definable virtual-meter expressions**, not hardcoded formulas. +- **Electricity has 5 meters** (Haus, Netz, Auto, Solar 1, Solar 2) plus derived columns. Verified relations to reproduce: `Netz Einsparung = Haus − Netz` (a virtual meter `m1 − m2`, checked through `VirtualEvaluator`), `Ersparnis = Netz Einsparung × €/kWh`, `Kosten = Verbrauchskosten − Ersparnis`. Implement these as **user-definable virtual meters and cost rules**, not hardcoded formulas; prices are never inside an expression. The sheet's `Kosten` is Netz × price, which is why the bill bills grid import (D-34). - **Heating oil is the versatility stress test:** a consumable/tank model where consumption is derivable two ways — tank-level Δ, or burner runtime × rate (rate `fixed` from nozzle spec, or `empirical` = Δlevel ÷ Δhours). Early rows (1997–2004) carry deliveries only (no burner hours yet). Includes cm→litre dipstick calibration and forecast-to-empty. ## Git diff --git a/README.md b/README.md index 127e4a7..a7b11f8 100644 --- a/README.md +++ b/README.md @@ -16,27 +16,71 @@ full design. - **Immutable raw readings** on a TimescaleDB hypertable; a normalized, append-only **consumption** layer on top — reproducible, auditable. - **Seven measurement modes** (cumulative/generation registers, burner runtime, tank/consumable, - direct delta, instant rate, virtual). Handles meter swaps, counter resets, tank dip-sticks with - calibration, and **virtual meters** defined by an expression (PV self-consumption, savings, net). + direct delta, instant rate, virtual). Handles meter swaps, counter resets and tank dip-sticks with + calibration. +- **Virtual meters** with a validated formula over other meters: sum, difference or free formula, + e.g. `Solar 1 + Solar 2`, or self-consumption as `Haus − Netz`. They are analysed exactly like + physical meters: history, comparisons, source contributions, and costs where a cost rule applies. + They are computed from their sources on every read, so they never go stale. A missing source month + reads "no data", never a silent zero. - **Tariff engine** with time-ranged price history (unit/base/feed-in), scoped global / per type / - per meter; **cost categories** decoupled from energy types; meterless manual costs. -- **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw. -- **Dashboard**: cost KPIs with period-over-period deltas, "what costs most", a "what cost more/ - less" difference view, trends, a **PV/Solar panel** (generation, self-consumption, autarky %, - savings), an **oil/consumable panel** (tank gauge, deliveries, burner runtime, effective L/h, - forecast-to-empty) and a **per-meter detail view** (raw readings, consumption, sources, tariff - timeline, events), one-click reference-data load, CSV dry-run. -- **Per-energy-type flow pages** (Electricity, Water, …): a **Sankey diagram** of the meter chain — - a downstream meter is a *subsection* of an upstream one (main → car, pool, garden, …), arrow - thickness ∝ amount, with an auto-computed "Other/unmetered" remainder. Meters can have several - upstreams (a merge, e.g. grid + solar → house). -- **Admin UI**: full create/edit/delete for energy types, meters (with consumption recompute on - mode/baseline change, and cycle-safe upstream-meter wiring), ingest sources, tariffs, cost - categories, and MQTT/Home-Assistant connectors; a "Test connection" for Home Assistant; - effective-settings view. + per meter. The bill counts each energy type's grid import (or its household use), not every meter + that happens to exist. Standing charges are counted once per scope. **Cost categories** are + decoupled from energy types. Meterless manual costs are supported. The currency is configurable. +- **Rollups by local day and month**, written together with the consumption, so dashboards never + scan raw readings. The Settings page shows how far each meter's analysis data is built. +- **One period everywhere**: every page shares a period toolbar (month to date, last month, year + to date, previous year, last 12/24 months, all history, custom dates), a bucket size (day, week, + month or year) and a comparison (previous period, previous year, or any calendar year). The + selection lives in the URL, so reload, Back and shared links keep it. +- **Pages:** + - **Overview** of the selected period: cost with its composition, one card per energy type, + history chart, "what changed", and attention items with a direct fix. + - **Analysis** page to explore the portfolio, an energy type, a cost category, one meter or up to + six meters side by side, by quantity or cost. + - **Energy type** pages with Overview, History, a **Sankey flow** of the meter chain, and a meter + list. A downstream meter is a *subsection* of an upstream one (main → car, pool, garden, …), and + the unmetered remainder is shown as "Other". + - A **meter hub** with tabs for Analysis, Readings, Normalized data, Events, Tariffs and Sources + (Calculation for virtual meters). The record tabs are paged over the full history. + - **Solar** (generation, self-consumption, feed-in, autarky, savings, with setup help for missing + meter roles) and **Tanks & consumables** (last dipstick, estimate now, deliveries, burner + runtime, forecast). +- **Honest numbers**: a true zero, missing data, data that is only monthly, and a missing price + are shown differently everywhere, in cards, charts, tables and the **CSV export** of any view. +- **Admin UI**: full create/edit/delete for energy types, meters (with a calculation editor and + live preview for virtual meters, friendly meter roles, and a recompute when a change needs one), + ingest sources, tariffs (unit check; "Add tariff" links from a missing price open the editor + prefilled), cost categories, and MQTT/Home-Assistant connectors. Includes a "Test connection" for + Home Assistant and an effective-settings view. +- English and German UI, light and dark theme, usable down to phone width. - **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik). - **JSON config export/import** for portability; Docker Compose + multi-arch image. +## Upgrading to 0.4.0 + +0.4.0 reworks the dashboards and how analysis and costs are computed. Read +[`docs/RELEASE_NOTES.md`](docs/RELEASE_NOTES.md) first: some figures change on purpose. Back up the +database (`pg_dump`), then start the new image once and let it finish: + +- **The first start rebuilds every meter's analysis data** (normalization revision 3): consumption, + the new day/month rollups and coverage. This happens before the web server listens, so the app is + unreachable while it runs and Compose may report the container unhealthy. Let it finish rather + than killing it. The time grows with the number of raw readings: about 0.1 s per monthly meter, + ~0.6 s per meter with ten years of daily readings, ~1.4 s per meter with a year of hourly + readings. A 1,000-meter test dataset (1.3 M readings) took about six minutes. Progress is logged, + and a meter that fails is retried at the next start. +- **Virtual meters without a formula** get the sum their links imply stored as an explicit formula. + The log names the meters converted and those that still need configuration. +- **The bill changes**: the grid meter is billed instead of every meter of a type, feed-in is + credited only on a grid-export meter, a missing tariff is "not priced" instead of 0, and standing + charges count once per scope. The seeded demo now matches the spreadsheet's yearly costs. +- **The REST API only adds fields**: see the release notes for `costStatus`, `costAvailability`, + `costRule`, `notCosted`, `missingPrices`, `status` and `latestMonth`. +- **Rolling back** to 0.3.0: the old version ignores the new tables and never read the dropped + continuous aggregates. Consumption stays as 0.4.0 booked it until each meter next ingests a + reading. Stored virtual-meter formulas remain; 0.3.0 ignores them and sums links again. + ## Upgrading to 0.3.0 This release changes where consumption lands. Back the database up first (`pg_dump`), then start the @@ -89,6 +133,8 @@ Configuration is via environment variables (`Section__Key` double-underscore map | `ConnectionStrings__Default` | PostgreSQL/Timescale connection string | | `MeterVault__TimeZone` | IANA timezone for buckets, display **and month attribution** (default `Europe/Berlin`). Changing it re-derives every meter's stored consumption at the next start, and historical monthly figures can shift. It must be an id both .NET and PostgreSQL know; anything else falls back to UTC and is reported in the log. | | `MeterVault__Locale` | Default UI language, `en` or `de` (default `en`). Each visitor can switch it from the app bar; the choice is remembered in a cookie. | +| `MeterVault__Currency` | Currency code of every amount (default `EUR`). Tariffs in another currency are reported as not fitting, never converted. | +| `MeterVault__RawRetentionDays` | Shown on the Settings page but **not enforced**: raw readings are kept, because every recompute rebuilds a meter from them. | | `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header | | `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) | | `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy | diff --git a/docs/ANALYSIS_IMPLEMENTATION_NOTE.md b/docs/ANALYSIS_IMPLEMENTATION_NOTE.md new file mode 100644 index 0000000..e7dcde9 --- /dev/null +++ b/docs/ANALYSIS_IMPLEMENTATION_NOTE.md @@ -0,0 +1,710 @@ +# Analysis rework: implementation note + +Companion to [DASHBOARD_ANALYSIS_CHANGE_BRIEF.md](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md). This is the Phase 1 +deliverable that resolves the brief's open choices. Every decision has an ID (D-nn) so code, tests and the final +report can refer to it. Written against `c0f52db`; revised after an adversarial design review. + +The note was kept current through Phase 5: +- §11: amendments from the Phase 1 module review. +- §12: amendments from the acceptance review. +- §13: decisions recorded with the final documentation. +- §9 and §10: extended with what the implementation measured and changed. + +The outcome is in [ANALYSIS_REPORT.md](ANALYSIS_REPORT.md), the user-facing changes in +[RELEASE_NOTES.md](RELEASE_NOTES.md). + +## 1. What the code review established + +The brief's findings A01–A14 are confirmed by the source, with these refinements: + +- **Nothing evaluates a virtual meter.** `VirtualNormalizer` and `ExpressionEvaluator` run only in tests. + `NormalizationService` skips virtual meters. The only value in production is `FlowService`'s sum of + incoming links, which ignores any stored formula. SDD §14.1 is not implemented in either direction. +- **The expression evaluator is unsafe for user input.** It has no AST, unknown identifiers evaluate to 0, and + recursion is unbounded. +- **Consumption rows store only the interval end.** There is no unit column, and `Estimated` covers four + different cases. Coverage cannot be recovered from sums; it has to be captured during normalization. +- **Month division only exists for cumulative and generation counters.** Tanks, runtime, direct-delta and + instant-rate modes book a whole interval at its end. The same is true of swaps, resets, decreases and first + readings. +- **The continuous aggregates cannot be used:** they are Berlin-only, materialized-only on TimescaleDB ≥ 2.13, + and never backfilled. No reader uses them, yet they are refreshed hourly. +- **Raw retention is not implemented.** Turning it on would destroy history, because every recompute rebuilds a + meter from the readings that remain. +- **The seeded costs are wrong in a way the spreadsheet proves.** The sheet bills `Kosten = Netz × price`, but + the seed prices Haus + Netz + Auto. The seed is also missing the water price rise to 7.00 €/m³ in 2026. +- **The tooling gaps are real.** There is no clock abstraction, no bUnit or Playwright, and the API responses + have no contract tests. + +## 2. Periods and comparisons + +- **D-01 Clock.** `TimeProvider` is registered. Pages and API endpoints read "now" once per request and resolve + a period with the pure resolver, then pass the resolved period down. Services never read the clock. Where one + genuinely needs "now" (freshness, forecast), it takes a trailing optional `TimeProvider? time = null`. Tests use + a small `FixedTimeProvider`. +- **D-02 Presets and URL tokens.** + - `period=mtd|last-month|ytd|prev-year|12m|24m|all|custom`, with `from`/`to` (yyyy-MM-dd) used only for + `custom`. + - Overview default: `mtd`. History pages default: `12m`, which is 12 calendar buckets ending with the current + partial month. + - `all` spans the availability metadata (D-19), not a fixed century. + - A page default applies only when no period key is present. An invalid token falls back to the default and + shows a notice. +- **D-03 Bounds.** + - A period resolves once, in the instance zone, into two forms: a local inclusive date range for display, and + a half-open UTC range `[from, to)` for queries. + - `to` is the local midnight after the end date, or the captured "now" for to-date periods. + - Quantities, costs, comparisons and exports all use the same bounds. +- **D-04 Now and the future.** + - Actual figures stop at "now". A row counts as recorded after now when its source interval ends after now. + Examples: a current-month label row, a future-stamped Tasmota row. + - Such rows are excluded from actuals and reported in a separately labelled "recorded after now" block (rows, + amount, dates), with an attention item. + - A range entirely in the future reports "not yet occurred". +- **D-05 Buckets.** + - Buckets are `day|week|month|year|auto`. Weeks start on Monday in local time. + - `auto` picks one bucket for the whole chart: the coarsest resolution any plotted series needs, at most 400 + points. + - An explicit bucket above 400 points is refused with a coarser suggestion. + - Bucket bounds are local midnights, clipped to the period. +- **D-06 Comparisons.** + - `compare=none|prev-period|prev-year|year:YYYY`. `year:YYYY` needs a year-aligned range. + - Shifting uses local calendar units, never durations: + - Whole years shift by years, and whole months by months. Anything else shifts by days. + - `12m`/`24m` compare with the N months before. `all` has no comparison. + - The cut-off maps as local date plus wall time: + - A nonexistent time takes the first valid instant after the gap. + - An ambiguous time takes the occurrence with "now"'s offset if one matches, otherwise the first occurrence. + - A day that does not exist in the target month (31st, 29 Feb) cuts at that month's end. +- **D-07 Matched coverage.** + - A change figure is "confident" only over the range both periods actually cover. The current period's covered + range is shifted, intersected with the comparison's coverage, and trimmed to whole buckets where resolution is + coarser than the cut. + - Both requested ranges and the matched range are shown. + - An empty match means "not comparable": absolute values only, no percentage. +- **D-08 Change figures.** + - The absolute difference is always shown. + - The percentage is "not applicable" when the baseline is ≤ 0 or unavailable. + - Colours depend on the metric: more consumption is not "good", more generation is. +- **D-09 Projections** are separate and labelled "Projection (straight-line from N days)". + - Method: the covered rate × the remaining days. Standing charges are added exactly per day. + - A projection is suppressed when: + - coverage ends more than 2× the meter's typical interval before now; + - covered elapsed time is under 7 days (month) or 30 days (year); + - the resolution is coarser than the period. + - A change chip never compares a projection with an actual. + +## 3. Data layer + +- **D-10 Engine intervals.** Every `Consumption` row carries its source interval: `IntervalStart`, + `IntervalEnd` and `Divided`, as EF-ignored properties, so the schema does not change. Each mode sets them: + + | Mode | Interval | + |---|---| + | Counters | previous effective reading → this one, with `GapSegment` bounds for divided shares | + | Runtime | previous effective time | + | Tank | previous TankLevel event | + | Instant rate | previous sample | + | Direct delta | previous reading, or the labelled month for a label | + | First reading | the labelled month for a label; `[InstalledAt, t]` when set; otherwise an unknown start (D-14) | + + `Coalesce` keeps the minimum start and the maximum end. +- **D-11 Midnight stamps.** A non-label row whose interval end falls exactly on a local midnight is stamped + 1 second earlier, inside the day it describes, mirroring `InsideSegment`. Label rows keep `StampTime`. + `[from, to)` stays everywhere. +- **D-12 Tables.** These are plain tables, not hypertables. Each has an FK to `meter` with `ON DELETE CASCADE`. + They are written by `RecomputeMeterAsync` in the caller's transaction, by diff, so only changed rows are + touched. + + | Table | Key | Columns | + |---|---|---| + | `consumption_rollup` | `(meter_id, day, kind)` | `amount`, `measured`, `manual`, `imported`, `estimated`, `rows`, `flags` (baseline-delta, divided) | + | `consumption_rollup_month` | `(meter_id, month, kind)` | same columns; month and year reads use it | + | `meter_coverage` | `(meter_id, span_from)` | `span_to`, `resolution_class`, `divided_at_months`, `gap_reason` | + | `meter_rollup_state` | `(meter_id)` | `revision`, `zone`, `normalized_unit`, `kind`, `built_at` | + + All local dates use the configured zone. +- **D-13 Coverage runs.** + - Consecutive intervals of the same resolution class merge into one run. The classes are ≤ 1 h, ≤ 1 day, + ≤ 7 days, ≤ 1 local month, and coarser. + - An interval longer than a month is its own run. + - These open a gap instead of coverage: + - an unexplained decrease; + - a reset without PrevValue; + - an instant-rate gap longer than max(1 h, 10 × the median sample interval); + - deliveries before a tank's first level. + - Coverage is capped at now. +- **D-14 Bucket status.** + - **missing:** no run overlaps the bucket. + - **partial:** runs cover only part of it. + - **unresolved:** an undivided interval crosses the bucket edge by more than 5 % of the bucket length. The only + exception is an edge at a local month boundary the normalizer divided at. + - **available:** everything else. An available bucket with no rows is a true zero. + - **Opening balance:** a first reading with unknown start marks its bucket "partial (opening balance, start + unknown)". It is excluded from comparisons and projections, and the UI offers to set an install date. + - Provenance is a separate dimension, derived from the per-quality amounts plus `derived` for virtual meters. +- **D-15 Reading a period.** + - Rollups (month table for month/year buckets, day table otherwise) cover complete local days. + - For at most two partial edge days per range, one direct `consumption` query covers + `[edge-day midnight, cutoff)` (`meter_id = ANY(@ids)`). + - Each request makes one query per table and one tariff load. Virtual dependencies are expanded in memory + first. The 400-point and 6-series limits are enforced before any SQL runs. +- **D-16 Rebuild.** + - `CurrentRevision` becomes 3, because the engine books differently (D-11, intervals). + - The startup upgrade rebuilds consumption, rollups and coverage, and records `meter_rollup_state`. + - A meter whose state is missing or outdated (revision, or zone ≠ the reader's zone) reads as "analysis being + prepared", never as "no data". + - The migration purges derived rows of virtual meters. `RecomputeMeterAsync` purges them if a meter becomes + virtual. + - The upgrade skips and logs any meter whose oldest consumption predates its oldest reading or event, instead + of truncating history (D-44). +- **D-17 Continuous aggregates.** The new migration removes their policies and drops the three views. + `Monthly_continuous_aggregate_refreshes_and_matches_base` is replaced by a rollup-equals-consumption test + (water Dec 2022 = 14 m³). +- **D-18 Freshness.** + - The last reading or event time is the freshness mark. + - A live source is stale when that time is older than the larger of 3 × the median of its last 20 intervals + and 3 × its poll interval. + - Import-only meters are "historical", never "stale". +- **D-19 Availability.** + - A quantity scope's availability is its coverage. + - A cost scope's availability is its billed meters' coverage plus its manual costs' `PeriodStart` days. + - Both are capped at now. + - "Latest period with data" is the latest local month ≤ now in that union. It is returned with its month and + basis (meters / manual / both). + +## 4. Quantities, units and totals + +- **D-20 Normalized quantity.** A Core function `NormalizedQuantity(meter, tank, definition)` returns + `(kind, unit)`. + - Kinds: consumption, generation, export, runtime, and for virtual meters also net or indicator. + - Units by mode: + + | Mode | Unit | + |---|---| + | RuntimeCounter | `h`, or the tank unit with a Fixed rate (kind stays runtime; provenance estimated) | + | InstantRate | the rate unit without `/h` (W→Wh, kW→kWh) | + | ConsumableBalance | the tank unit | + | Virtual | its declared result unit | + | Others | `Meter.Unit` | + + - Aliases are normalized (m3 = m³). + - The normalized unit is stored in `meter_rollup_state`. Raw units appear only on the Readings tab. +- **D-21 Roles.** `total_load`, `grid_import` and `grid_export` are unique per energy type; saving a role moves + it and says who held it. The editor shows localized names and one-line meanings, and offers each role only for + compatible modes. +- **D-22 Per-type totals algorithm.** Pure and ordered: + 1. **Supply meters** are grid_import or grid_export meters, GenerationCounter meters, and generation-kind + virtual meters. A link out of a supply meter is a *supply* edge and never makes its target a submeter. + 2. **Containment.** A link from a physical, consumption-kind, non-supply meter makes the target a breakdown of + its parent. + 3. **Measures per type:** + - *Use* is the total_load meter if there is one, otherwise the consumption roots. Consumption roots are + physical consumption-kind meters that are not supply meters, have no containment parent and are not + runtime meters. Tanks count. + - *Grid import* is the grid_import meters. + - *Export* is the grid_export meters, which are never consumption. + - *Generation* is the GenerationCounter roots. + - *Runtime* is the runtime meters. + 4. Measures are never added across units. + 5. Virtual meters are analysis views. Retired meters keep their history. + 6. Seeded result: use = {Haus}, breakdown = {Auto}, grid import = {Netz}, generation = {Solar 1, Solar 2}, + analysis-only = {Summe Solar}, runtime = {Brenner}, water use = {Wasser}, oil use = {Öltank}. +- **D-23 Override.** + - `Meter.Meta.totals` is `auto|always|never`. `always` on a virtual meter replaces its expanded dependencies in + that measure and in the bill. `always` on a meter whose ancestor or dependent is already counted is refused + on save, naming the other meter. `never` removes a meter from the measures it would join. + - The resulting cover is shared by the quantity totals and the bill. +- **D-24 Lifecycle.** Outside `[InstalledAt, RetiredAt]`, when set, a meter contributes a known zero to totals + and to virtual evaluation. + +## 5. Virtual meters + +- **D-25 Definition.** + - `Meter.Meta` holds `expression`, `referencedMeterIds` (always derived from the expression and rewritten on + save), `resultKind` (consumption|generation|net|indicator), `resultUnit` and + `costRule` (none|sourceCosts|ownQuantity). + - Topology links never define a calculation. +- **D-26 Formula.** + - The grammar is the existing one (`+ - * /`, parentheses, numbers). It is parsed to an AST, with limits of + 2,000 characters and nesting depth 64. + - References are `m`; any other identifier is invalid. + - Validation on save and on read covers: syntax, unknown or self references, cycles through nested virtual + meters (with the path), and kind/unit. + - Kind/unit rules for operands: + - `+`/`−` need the same unit and kind, or a declared `net`. + - Meter × or ÷ meter needs a declared `resultUnit` and kind `indicator`. + - Indicators are non-additive, never totalled and never costed. +- **D-27 Evaluation.** + - Evaluation runs on read, per bucket, from the sources' rollups. + - A virtual meter's coverage is the intersection of its sources' coverage, and its resolution is the coarsest + among them. + - A missing source makes the bucket missing (strict); an observed zero is a valid input. A non-finite result + makes it invalid, with the reason. + - A period total is the formula applied to the sources' totals over the joint coverage. It is partial when that + coverage is smaller than the period. For a linear formula without a constant this equals the sum of its + buckets; otherwise the series is marked non-additive. + - The result carries every source's series, status and dependency path. +- **D-28 Legacy definitions.** + - At startup, an expression-less virtual meter whose same-type incoming links name sources of one unit and + kind gets the equivalent explicit sum. Meters are processed in dependency order, the run is idempotent, and + the counts are logged. + - Anything else is flagged "needs configuration". + - Until converted, the reader evaluates the implied sum with status "legacy — confirm". + - `ReferenceDataImporter` writes Summe Solar's definition directly: `m(Solar 1) + m(Solar 2)`, generation, + kWh. +- **D-29 One evaluator.** `VirtualNormalizer` is removed from `NormalizationEngine.CreateDefault`. Its tests and + the golden Netz Einsparung reconciliation move to the new evaluator over month buckets (≥ 20 matches, ±1 kWh). +- **D-30 Flow.** + - A pure-sum virtual meter's incoming edges are its calculation dependencies, drawn at each source's own value + and marked "calculated". + - Other virtual meters appear only in the table view. + - Links are capped at the parent's value, and proportional splits are marked estimated. + - The Flow tab gets "Manage connections". +- **D-31 Editor.** + - Sum, Difference and Advanced modes, with source pickers by name (showing unit, kind and install/retire + dates) and a live preview for the selected period. + - On a virtual meter's page, a Calculation tab replaces Sources. Register details and Readings are removed. + Events keeps Note. + - Saving a Sum offers to sync the incoming links. +- **D-32 Export/import** carries `meter_link` and remaps meter ids inside definitions. +- **D-33 Deleting a meter** lists the virtual meters that depend on it and requires confirmation. + +## 6. Costs + +- **D-34 Billing.** + - Per energy type, the grid_import meters are billed if the type has one, otherwise the *use* meters (D-22). + - Generation meters are never billed. + - The feed-in credit is the FeedIn price × the export of grid_export meters. + - Runtime and virtual meters are not billed unless D-39 applies. +- **D-35 Separately billed submeter.** A containment child with an applicable meter-scoped UnitPrice is billed at + its own price. Its monthly quantity is subtracted from its billed ancestor's for pricing. Quantity totals do + not change. +- **D-36 Prices.** + - The monthly convention is kept: the price valid on the 15th of each local month. + - Every bucket size is priced month by month, so week and year buckets are split by local month. Changing the + bucket never changes a total. +- **D-37 Tariff applicability.** + - A UnitPrice or FeedIn tariff applies only when the unit denominator matches the meter's normalized unit. + Known scales are converted (ct, per 100 L, per MWh). + - A parsed mismatch makes the cost "unavailable (unit)". An unparseable unit applies, with a warning. + - BasePrice units are per day, per month (the default) or per year. + - The tariff editor validates units on save and states that Bonus, Discount and Tax are not applied yet. +- **D-38 Coverage.** + - A billed scope with no UnitPrice tariff at any date is "not priced (no tariff)". That is an attention item, + not "unavailable". + - A gap inside a priced scope's tariff history makes the cost "unavailable" for those months. + - An explicit zero tariff is a valid zero. + - A missing FeedIn price is reported only where a grid_export meter exists. +- **D-39 Virtual costs.** + - `sourceCosts` adds the sources' metered costs. It is allowed only for pure sums and excludes scope-level + standing charges. + - `ownQuantity` prices the virtual quantity with normal precedence. It is allowed only for linear formulas + without a constant. + - The default is `sourceCosts` for pure sums and `none` otherwise. The rule is named next to every virtual + cost. + - A virtual meter is part of the bill only through D-23. +- **D-40 Standing charges.** + - A standing charge accrues per local day, at value ÷ days in that local month, over the scope's service + period. The service period runs from the earliest InstalledAt or first data to the latest RetiredAt or now, + regardless of reading gaps. + - Meter-scoped charges stay on their meter. + - Type- and global-scoped charges are their own rows ("Standing charge — " / "— global"), never split + across meters. +- **D-41 Manual costs** are booked in full on their `PeriodStart` local day, when that day is in `[from, to)` + and ≤ today. `PeriodEnd` is informational. A cost with `MeterId` set goes to that meter's categories. +- **D-42 Categories.** + - A category's cost is the priced cost of the non-overlapping cover of its members, using the bill algorithm + restricted to them, plus its manual costs. For example, Strom {Haus, Netz, Auto, Solar 1, Solar 2} gives + Netz × price, and a category {Auto} gives Auto × price. + - A type- or global-scoped standing-charge row joins a category only if the whole type (or, for global, every + billed meter) is a member. + - The composition is the disjoint categories, plus Uncategorized, plus standing-charge rows, and it reconciles + to the bill. + - A category that overlaps another, or covers meters outside the bill, is an "overlapping view" and stays + outside the composition. + - The donut is drawn only when every slice is ≥ 0; otherwise signed bars are used. +- **D-43 Currency.** `MeterVault__Currency` is used everywhere through one `Format.Money`. +- **D-44 Seed.** + - The water tariff 7.00 €/m³ from 2026-01-01 is added. + - Summe Solar gets an explicit definition. + - The seed tariffs are otherwise unchanged. + - Golden: the seeded yearly bill equals the sheet's `Jahreskosten` within ±0.02 € for 2022 (421.52), + 2025 (7,907.64) and 2026 (2,940.19). This is computed on a frozen clock after 2026-05-31, with the tank + unpriced. + - 2023 and 2024 differ by 3.78 € and 0.46 €, because the sheet rounds its displayed prices. That is documented + and not tuned away. + +## 7. API + +- **D-45 Compatibility.** + - Contract tests for `/consumption`, `/cost` and `/dashboard/summary` are written before anything is rerouted. + - Every existing field and type is kept. + - `/consumption` and `/cost`: + - They keep exact-instant bounds, now converted with `ToUniversalTime()`. + - `cost` stays numeric (0 when nothing is priced), and `costStatus` and `missingPrices[]` are added. + - Virtual meters return evaluated values with a status. + - `/dashboard/summary`: + - It keeps its calendar month and year windows (legacy semantics, documented) but uses the new billing set. + - It adds `deltaPercentApplicable` and `latestMonth`. + - The numeric change is listed in the release notes. + +## 8. Pages, navigation, state + +- **D-46 URL state.** + - The query parses into an immutable `AnalysisQuery` value. Analysis reloads only when that value changes, + with a generation counter and cancellation. + - An action drop or tab change never reloads it. + - Toolbar and tab changes replace the history entry; drill-downs push. Defaults are never written on load, so + a deep-linked dialog is not dismissed. + - Initial loads stay in `OnInitialized`/`OnParametersSet`, because render tests read prerendered data. +- **D-47 Keys.** + - Meter tabs are `analysis|readings|normalized|events|tariffs|sources|calculation`. Legacy `consumption` maps + to `normalized`. On virtual meters, `sources` maps to `calculation` and `readings` to `analysis`. + - Energy-type tabs are `overview|history|flow|meters`. + - Analysis page scope is `scope=portfolio|type|category|meter|meters` with `id`/`ids` (at most 6), plus + `metric=consumption|generation|export|runtime|net|cost|balance`. + - Link helpers append the new keys after the existing ones. +- **D-48 Navigation.** + - Sidebar entries: Overview, Analysis, Meters, Energy types (with a retry item on error), Specialized views + (Solar, Tanks & consumables — always listed, with a setup state when unsupported), Data import, + Configuration. + - Expanded groups persist in a cookie. `NavState` gains `MetersChanged`. + - Breadcrumbs (Overview → type → meter) carry the period, and Back returns to the parent. + - Search shows a text label from the md breakpoint up, and its results link to Analysis (with the period) plus + quick entry. +- **D-49 Theme.** + - A scoped `ThemeState` is backed by a cookie that App reads, so prerender and the language switch keep the + mode. + - Charts use a transparent background and the theme's mode, and are re-keyed on theme or result change. + - Units and currency go into JS formatter strings. Points are nullable; there is no smoothing and no joining + across gaps. +- **D-50 Tables.** + - Readings, Normalized data and Events are paged server-side (100 rows), keyset-ordered, with `from`/`to` + filters. + - The manual-entry dialog runs its own queries (latest reading, reading at T with flags, boundaries), so its + verdicts never depend on a page of rows. +- **D-51 Drill-down.** Clicking a chart bucket keeps the scope, sets the bucket's range and the next finer + supported bucket. If there is none, it opens Normalized data filtered to that bucket. +- **D-52 Deep links.** + - Tariffs: `/admin/tariffs?scope=&id=&component=&from=&action=new` opens a pre-filled new-tariff dialog. + Missing-cost explanations link there with the first uncovered month. +- **D-53 Attention items.** Missing required price (scope and first month), stale live source, invalid or + unconverted virtual definition, recorded-after-now rows, possible overlap (a total_load and a grid_import root + that are not linked). +- **D-54 Solar and consumables.** + - Both adopt the shared toolbar, cards and charts. Units come from D-20. + - Solar shows a setup card for each missing role. + - Tanks show "Last dipstick: on " separately from "Estimated now (incl. deliveries since)". For a + historical range they show the balance at the range end. Deliveries are filtered to the range. + - The forecast is suppressed when the dipstick is older than 60 days. +- **D-55 CSV export.** The analysis table as CSV: one row per bucket and series, with local ISO bucket bounds, + timezone, invariant numbers, empty cells for unavailable values, and status, provenance, cost, cost status, + currency and the comparison value. Served by an App endpoint that takes the same URL keys. + +## 9. Evidence, limitations, deviations + +- **D-56 Evidence.** + - Frozen-clock tests cover New Year, Berlin DST in spring and autumn, 29 Feb, 31 Jan → Feb, and New York. + - Seeded goldens: D-44, the D-22 classification, and water Dec 2022 = 70 € / 14 m³. + - Worked virtual examples: A+B, A−B, missing vs zero, nested, cycle, division by zero. + - A synthetic generator (test trait) for 1,000 meters × 10 years with recorded timings. + - Screenshots and a manual checklist (EN/DE × light/dark × 360/768/desktop) from the seeded instance. No bUnit + or Playwright is added. + - *As implemented:* + - The frozen-clock, seeded-golden and worked-virtual suites exist as planned. `docs/ANALYSIS_REPORT.md` lists + them with counts. At the end: Core 1,733 tests, Integration 746. + - The synthetic generator and timings are `tests/Integration.Tests/Performance` (trait `Category=Performance`, + skipped unless `METERVAULT_PERF=1`). + - The manual checklist ran as Chrome DevTools Protocol scripts against seeded instances: four acceptance + reviewers plus the page agents, in EN/DE, light/dark, at 1440/390/360 px. Those scripts and the screenshots are + outside the repository. Server-rendered pages are covered by `HtmlRenderer`-based render tests in EN and DE. +- **D-57 Limitations.** + - Raw retention is not implemented; `/admin/settings` labels it "not enforced", and the Readings tab explains + it. The brief's "Retained history" scenario is a documented blocker. + - Monthly imports are never interpolated to days (SDD §8.7 / §14.2 unchanged). + - A full recompute still runs per live reading. + - Bonus, Discount and Tax tariffs are not applied. + - *Measured and found later (see `docs/ANALYSIS_REPORT.md`):* + - The per-reading recompute is linear in a meter's reading count: ~0.1 s for a monthly meter, ~1.4 s for a year + of hourly data. The startup rebuild is ~20 % slower per meter than in 0.3.0. + - The freshness query (`AnalysisQueries.RecentReadingsAsync`) has no time bound, so it plans across every raw + chunk. + - The window-sum query (`AnalysisQueries.WindowSumsAsync`) gets no plan-time chunk exclusion. + - Both grow with history length, and neither is fixed. + - The billing basis cannot change month by month (A-17). + - Batteries are not modelled in Solar's calculated feed-in. + - The Solar page has no CSV export, because the export has no derived measures. +- **D-58 SDD deviations.** + - §14.1: virtual meters are computed on read, and nothing is materialized. + - §5.4 / §10: the configured zone is used, and rollups replace the continuous aggregates. + - §8.1: the Overview shows one selected period. + - §7.4: prices inside expressions are not supported; the `ownQuantity` cost rule covers savings. + - *Also marked in the SDD at the end of the rework:* + - §3 (FR-9, FR-11, FR-12, FR-16), §4.1 / §4.2 (no aggregates, the two-reader pipeline) and §5.1 (the new tables). + - §5.5 (raw retention not enforced, D-57). + - §7.1 (revision 3), §7.3 (tank "now" vs period), §7.5 (the bill, D-34 – D-43). + - §8.0 (the shared contract), §8.2 – §8.7 (the pages), and the monthly-history note (no interpolation). + - §9 (additive API fields, D-45, A-21), §10 (half-open bounds, `TimeProvider`), §11 – §13 (layout, milestones, + tests) and §14.1 – §14.4 plus the new §14.9 – §14.14. + - Appendix B (Ersparnis is not an expression). + +## 10. Deliberate behaviour changes + +| Area | Old | New | +|---|---|---| +| Seeded Strom bill | Haus + Netz + Auto priced | Netz (grid import) billed; matches the sheet | +| Feed-in | credited on all generation | credited on grid_export only | +| Missing tariff | cost 0 | not priced / unavailable (D-38) | +| Standing charge | per meter per month with data | once per scope, per day of service | +| Midnight readings | booked in the next day | booked in the day they close (D-11) | +| "Last 12 months" | 13–14 buckets, including a future month | 12 buckets, actuals up to now | +| Virtual meters | no analysis; flow sums links | full analysis from the formula | +| Overview "this year" | full year vs complete previous year | selected period vs matched coverage | +| Currency | hard-coded € | configured currency | +| Continuous aggregates | refreshed hourly, unused | dropped | +| API | — | additive fields only; the summary's values follow the new bill | + +Added as the amendments and pages landed (the release notes, `docs/RELEASE_NOTES.md`, list them for users): + +| Area | Old | New | +|---|---|---| +| Year and week buckets | a year priced at the 1 July price, a type/global base price per meter and bucket | every bucket priced month by month at the price of the 15th (D-36) | +| Separately priced subsection | added on top of its parent | billed at its own price, out of its parent (D-35, A-19) | +| Manual costs | in the summary but not the trend; a cost later this month counted at once | once, on its start day, once that day has come, everywhere (D-41) | +| Categories | sum of member meters' costs | priced non-overlapping cover; overlapping categories are views (D-42) | +| Intervals longer than a month (tank, runtime, direct delta) | booked whole in the later month, zeros between | months "only coarser data"; longer buckets priced when the months share one price (A-16) | +| Months without a grid meter in service | — | cost unavailable, with an attention item (A-17) | +| Meter fee on a meter no line prices | per meter and bucket | its own standing-charge row (A-18) | +| Rows recorded after now | counted in to-date totals | reported apart; a day holding one reads partial (D-04, A-14, A-20) | +| Summe Solar / generation sums | not costed (no analysis) | analysed; cost rule `none` (A-15) | +| Percentage against a negative baseline | divided by its absolute value | not applicable (D-08) | +| `/api/v1/cost` of generation, runtime, invalid meters | `Priced` (a generation meter could carry a negative feed-in cost) | `NotPriced` with `costRule`/`notCosted` (A-21) | +| `/api/v1/consumption` months without data | 0 | left out (only rows holding quantity data) | +| Deleting a meter or energy type | its scoped tariffs stayed behind and could be restored onto another meter | deleted with it (`EntityDeletion`); export/import skips such orphans (A-37) | + +Tests rewritten on purpose (none weakened; each rewrite states the new rule): +- `MeterPeriodServiceTests`: deleted with `MeterPeriodService`. Its cases moved to `MeterAnalysisLoaderTests`, with + the virtual-null case replaced by positive and error-state cases (the worked A+B example, missing vs zero, an + invalid calculation). +- `FlowServiceTests.Virtual_sum_meter_aggregates_its_upstreams` became `Virtual_sum_meter_is_its_formula`, plus + legacy, non-sum, capped-link, missing-sub-meter, other-unit and after-now cases. +- The CAgg test became `CostReconciliationTests.Monthly_rollup_equals_the_consumption_it_sums`. `SchemaTests` now + pins that the aggregates and their jobs are gone. +- `FormatCultureTests`: the currency case became `Money_is_in_the_configured_currency_written_the_readers_way`, plus + formatter cases. +- `LocalTimeEntryTests.Tab_keys_map_to_panel_indexes` became `Tab_keys_resolve_by_key_and_mode`. +- `VirtualMeterTests` and `ElectricityReconciliationTests.Netz_einsparung_virtual_matches_the_sheet` now run through + `VirtualEvaluator` (D-29). +- `ExpressionEvaluatorTests`: deleted with the evaluator. `FormulaParserTests` pins that unknown identifiers are + errors. +- `DashboardRenderTests`: the literal labels of the old pages, replaced by assertions on the new ones in EN and DE. +- `DashboardServicesTests`: four Solar and tank tests moved to `Specialized/` with the new services' assertions. +- `AnalysisChartModelTests`: a missing bucket is marked "–", not "*" (A-28). `MeterAnalysisLoaderTests` reads the + cost change's new type (A-23). `AttentionItemsTests` has the specific duplicate-role text. + +## 11. Amendments after the Phase 1 module review + +These refine the decisions above where the independently built Core modules met. + +- **A-01 Opening balance.** An opening balance is never a coverage run. The rollup day it is booked in + carries a baseline-delta flag, which the coverage evaluator takes as an input. Matched coverage gets the + booked stamps of opening-balance rows. `CoverageGapReason.OpeningBalance` is removed. +- **A-02 DividedAtMonths.** This is true when no interval of a run straddles a local month boundary undivided. + An interval is either divided at month boundaries, or lies inside one local month. A zero increase across a + month boundary counts as divided, because a register that did not move is exactly 0 in every month. +- **A-03 Divided intervals** are classified at most `Month`: they are never coarser for month and year + buckets. Runs are rejoined by source interval identity, not by adjacency. +- **A-04 Capping at now.** + - Stored runs are uncapped and carry `LastIntervalStart`. A reader caps a run at now, but when now falls + inside the run's final interval, the run ends at `LastIntervalStart`. That final interval's row is + recorded after now (D-04). + - A to-date bucket counts as fully covered when its coverage reaches within one interval of the run's class + of its end. The consumption since the last reading is not yet known, and the bucket is not reported + Partial for that reason. + - One module owns capping: `CoverageRuns.CapAt`. +- **A-05 Recorded after now (Phase 2).** Each rollup day stores the latest interval end among its rows. A day + whose rows end after now is reported as recorded after now, not as an actual. +- **A-06 Auto bucket** is chosen from the period's nominal range (ytd → month, mtd → day), so one URL renders + the same way all year. The point limit is checked on the elapsed range. +- **A-07 Roles.** + - Analysis reads roles only through `MeterRoleRules.Effective`: case-insensitive, and only for modes that + may hold them. + - Virtual meters never hold a role. + - A role is unique among meters that are not retired. A retired meter keeps its role for its history, and + each meter counts only within its own service period (D-24). +- **A-08 Virtual result kinds** are consumption, generation, net or indicator, nothing else. Save writes the + effective (inferred) kind, unit and cost rule, so readers never re-infer them. +- **A-09 One classifier, one unit table.** + - The resolution classifier lives once, in `Coverage`. + - `Units` (Quantities) is the only unit normalizer, and every module uses its comparer. +- **A-10 Comparison mapping details (D-06).** + - A range starting on a day the target month lacks (30 March → February) starts at the end of that month, + so a range starting 30 March matches from 1 March. + - A "now" in the second pass of an autumn fold maps to the end of the fold when the target date has no fold, + which keeps the mapping monotonic. + - Comparison buckets are paired with the current buckets by index (`ComparisonResolver.PairBuckets`), never + planned separately. +- **A-11 A separately connected heat pump** (its own supply, not below the main meter) is modelled as its own + energy type with its own grid_import meter. Containment children with their own price remain D-35. +- **A-12 Joint coverage.** The reader derives each virtual source's per-day coverage and bucket states from + `CoverageEvaluator`, and feeds them to `VirtualEvaluator`. The evaluator does not re-derive coverage rules. +- **A-13 Default comparison.** D-06 lists the comparisons without fixing a default. Every page compares with the + previous year when `compare` is absent (`prev-year`): the Overview's month to date with the same elapsed days a + year earlier, a history page's last 12 months with the 12 months a year before. Compared with the period just + before, a seasonal utility would show the season as a trend. Like every default it applies only to an absent key + and is never written into a URL; `compare=none` and `compare=prev-period` remain one click away. +- **A-14 Capping inside an earlier interval (A-04).** When now falls inside an interval that is not the run's last + (two readings stamped ahead, a reading stamped weeks ahead whose month shares are several intervals, a sheet row that + carries the current month's register into a later month), the stored run does not say where that interval starts. + The run then ends at the earliest instant it can start: the later of the local month start of now and now minus one + interval of the run's class; a coarse run ends where it starts. Such a run is always divided at months, because an + undivided interval across a month edge is its own run. So coverage never claims time whose row is recorded after + now: a label run gives up exactly the current month, finer data at most one interval. The shares of such an interval + that closed before now stay actuals, as A-05 reads interval ends per share. +- **A-15 Virtual source costs (D-39).** + - `sourceCosts` adds, for each physical source, what that source's own scope costs: a consumption source at its unit + price (or its bill line), an export source as its feed-in credit, a generation or runtime source nothing. A sum + whose sources price nothing is not costed, and the reason is named (generation, runtime). + - The sources come from the formula's weights, through nested pure sums, each once: `m1 + m1 - m1 + m2` is m1 and m2. + - A generation sum defaults to `none`, because generation is never billed (D-34). The seed and the legacy derivation + write the default, so Summe Solar is stored with `none`. A stored `sourceCosts` on a generation sum costs nothing. + - A sum over a nested calculation that is not a pure sum is not a sum of metered costs. Its default is `none`. A + stored `sourceCosts` stays valid for the quantity but is taken as `none` on read (not costed: "a source calculation + is not a plain sum"), and is reported as `CostRuleNeedsPureSum` for the editor to refuse on save + (`VirtualValidation.CostRuleProblem`, `IsSavable`). +- **A-16 Intervals longer than a month (D-36).** Pricing month by month left a meter whose reading intervals span + several months (a tank dipped every few months, a quarterly delta, burner hours read quarterly) without a cost at + every bucket size. When a bucket of several months holds an unresolved month, the cost engine also reads the bucket + whole. If every month the bucket has data in has the same price (the same tariff outcome and converted unit price, + D-37), the bucket costs its quantity at that price. A price change inside it leaves it unavailable, with an attention + item naming the meter and the months. Month buckets stay unknown; year buckets and period totals are priced, and the + bucket size still never changes a total. The legacy adapters no longer turn such an unknown cost into a priced 0: + `ConsumableSummary.CostKnown`, `MeterPeriodView.YearToDateCostKnown`, and `costAvailability` on `/api/v1/cost` + (additive, D-45). The dashboard summary keeps D-45's numeric legacy windows. +- **A-17 Months without a grid meter (D-34 with D-24).** The billing basis is chosen per energy type for all time. In + a month where no billed grid_import meter is in service on every day (before its install date, after it retired + without a successor) while a use meter in service measured something, the grid meter's known zero would bill that use + as free. Such months are unavailable instead, with an attention item naming the grid meter and the months. Switching + the basis month by month is deferred: the category composition (D-42) would need the same per-month basis to stay + reconciled with the bill. +- **A-18 Meter fees without a line (D-40).** A meter-scoped standing charge of a physical meter that no line of the + figure prices (a PV or house meter behind the billed grid meter) accrues as its own standing-charge row on that meter, + over its service period: in the type's bill, the portfolio and the meter's own scope. In the composition it is a row + like the type's: it joins the one disjoint category that holds its meter, and is a slice of its own otherwise. +- **A-19 Kaskade (D-35 with D-22).** A consumer with its own meter-scoped unit price, linked directly below a billed + grid_import meter with no house meter in between, is billed at its own price and taken out of the grid meters that + link to it. D-22 still reads that link as a supply edge, so the measures do not change. A priced meter nothing links + is still reported as an unused meter price. +- **A-20 Withheld days are not complete (A-05, D-14).** A-05 withholds a whole rollup day or month once a row in it + closes after now, and that can take rows recorded before now with it (a current-month label beside live readings + takes the day's live share). Coverage cannot see this, so such a bucket, and a total holding it, reads partial with + the issue "recorded after now", never available: an empty day is not a true zero. The rows stay in the "recorded + after now" block. + +## 12. Amendments after the acceptance review + +These refine decisions where the acceptance review found a gap. No golden bill or reconciliation figure changes. + +- **A-21 Not-costed meters on `/api/v1/cost` (A-16, D-45).** A meter without a cost rule (generation, runtime, an + indicator, a calculation that cannot be evaluated) returns `costStatus: NotPriced` and, as `costAvailability`, the + status of its quantity (`Invalid` for a loop or a division by zero) — never "Priced, Available" beside the numeric 0 + D-45 keeps. Two additive fields say why: `costRule` (`MeterCostRule`) and `notCosted` (`MeterNotCostedReason`). A costed + meter's month whose quantity is invalid or pending never reports an available cost either. Release note: physical + generation and runtime meters changed from `Priced` to `NotPriced`. +- **A-22 A category whose members price nothing (D-39, D-42).** The cost math is unchanged: a calculated view, a + generation or runtime meter adds nothing to a category. The cost reader now reports it + (`CostAttentionKind.CategoryPricesNothing`, with the category and the members), for a category scope and for every + category of a portfolio read. The Analysis page shows the explanation instead of "No data yet", the Overview lists the + category in its composition as "No cost – members not billed", and the meter editor says under a virtual meter's cost + categories that membership adds no cost. +- **A-23 One cost-change rule on every page (D-07).** The Overview's rule (`OverviewComparison.Between`: the totals when + both periods are complete, else the paired buckets complete on both sides, else not comparable) is used by the energy + type page, the Analysis page (cards and the table's total row) and the meter page (which now also states it from the + totals when both are complete), with the same "over the part both periods cover" caption. The Analysis page's + one-meter quantity view reads the meter's comparison cost for its cost card. +- **A-24 A measure's resolution (D-51).** A per-type measure carries the coarsest resolution of the meters it counts + (a virtual member's evaluated resolution), so a type or Solar view over monthly data never drills a month into days; + the Overview's own fallback is gone. Solar bounds drilling by every series it charts. Pages offer a click, a drill + column and a drill hint only where some bucket leads somewhere. +- **A-25 A virtual meter's bucket with nothing finer (D-51).** A virtual meter has no records, so where a physical meter + opens its Normalized data, a virtual meter's bucket opens the meter's own analysis over that bucket; its source + contributions link on to each source's records for it. The bucket that already is the whole view leads nowhere. +- **A-26 Nothing booked (D-19, D-41).** A cost bucket with no line, no charge, no manual cost and nothing missing stays + unknown in the engine (SeededBillTests) and now reads "No data" everywhere: never "Priced" beside "—", never complete, + and `Missing` (not `Available`) in the CSV export. +- **A-27 A tariff's value (D-38, D-52).** The tariff editor starts a new tariff without a value and refuses to save + without one, so the missing-price deep link cannot turn a gap into a free period by one click. A typed 0 for a unit + price, base price or feed-in is saved as the valid zero D-38 defines, with the note "A price of 0 makes this period + free of charge". +- **A-28 Chart marks (D-49, brief §4.3).** Bars are outlined in their colour, so a true zero is a line on the baseline + and a gap draws nothing; a bucket without a value is marked "–" (its own note), a qualified value keeps "*". A chart + with nothing to draw says why: data only coarser than the buckets (naming the resolution, with the interval that + shows it) or a cost without a price — "no data" only when there is none. A unit mismatch in an attention item says + what does not fit: the currency, a base price's period, or the meter's unit. +- **A-29 Record tabs and "now" (D-04, D-50).** The record tabs list the whole named range, so their toolbar shows those + dates (the end of the month for month to date), and every row dated after now carries an "After now" mark. +- **A-30 Preview period (D-31).** The calculation preview opens on the period of the page the editor was opened from + and offers every preset, custom dates and all available history (the sources' own dates, however old), through the + shared toolbar; a range too long for months previews in years. + +## 13. Amendments recorded with the final documentation + +The page agents and the integration made these decisions while building. They are implemented and tested, but were +not written down above. They are recorded here so the note stays the complete list. None of them changes a golden +figure. + +- **A-31 Page-specific URL keys (D-46, D-47).** + - The energy History tab uses `view=total|meters`. + - The Overview uses `chart=` for its chart selection. + - The record tabs reuse the page's `period`/`from`/`to`, and `all` there means no date bound. + - None of these keys belongs to `AnalysisUrlKeys`. They are written with replace and never reload the analysis. +- **A-32 Overview projection (D-09).** + - It is offered only for month or year to date, only from a complete figure, and only after 7 or 30 days. + - Metered use (net of feed-in credit) and standing charges are extended at their observed rate per elapsed day. D-09 + said standing charges are added exactly per day; that is not done. + - Manual costs are kept as booked, not projected. +- **A-33 Series on one chart (D-15, brief §7.4).** + - The Analysis page draws comparison overlays for at most three series. With more, the comparison stays in the + table, with a note. + - The energy History "individual meters" view charts at most six meters and links to the Analysis page for the rest. + - A category is analysed by quantity only when all its meters share one kind and unit. The meters are then shown + side by side and never added, because members can overlap. Otherwise the page explains why and offers the + alternatives. +- **A-34 Context and counts on the meter page (brief §7.2, D-50).** + - Events and tariff changes in the range are listed under the chart, not drawn on it: the shared chart has no + annotation support. + - Record tab labels carry no counts, because that meant counting every row on each load. Each table states its own + count, capped at 10,000. +- **A-35 Solar figures (D-54).** + - Self-consumption is total load − grid import, else generation − grid export. + - Feed-in is the grid export meter, else generation − self-consumption. The calculated form is labelled, and + batteries are not modelled. + - Site use is the total load meter, else self-consumption + grid import. + - Savings are self-consumption × the grid unit price, month by month through `CostCalculator`. The feed-in credit is + the cost reader's own line. + - Mixed units make a figure invalid, naming the units. +- **A-36 Tariff deep link (D-52, A-27).** + - The prefilled dialog opens once. `action`, `component` and `from` are then dropped from the address. + - `scope`/`id` stay and filter the list to the tariffs that can price that meter or type, with "Show all tariffs". + - The suggested unit follows the scope until the user types one. + - A stored tariff whose unit no longer fits shows an issue icon and cannot be saved again until the unit is fixed. + - Both admin and meter tariff lists show the effective end: the day before the next tariff of the same kind starts + (`TariffValidity`). +- **A-37 Deleting a meter or an energy type (D-32, D-33).** `tariff.scope_id` has no foreign key, so `EntityDeletion` + deletes the tariffs scoped to the meter or type together with it, in one transaction. For a meter, its readings and + consumption go too. Export/import no longer restores a tariff whose meter or type is gone onto whichever id replaces + it. +- **A-38 Where the toolbar sits (brief §7.2, §7.3).** + - The energy page has one toolbar above its four tabs: + - Interval and comparison show on Overview and History; metric and export only on History. + - A bucket refused for too many points is read again on auto, so every tab still shows figures while the toolbar + offers the coarser size. + - The meter page keeps its toolbar inside the Analysis tab, so its tab bar sits directly under the header. +- **A-39 Shell after the acceptance review.** + - Buttons, icon buttons, links, chips, tabs and nav links get a 2 px focus ring in the theme's text colour. + - `MeterVaultMudLocalizer` gives MudBlazor's own labels German text. The English values are MudBlazor's own. + - Meter → Sources links each source's connector to its editor, and the source dialog has "Edit connector". Both + keep the existing detour and its draft. + - The theme defaults to dark when no `mv-theme` cookie is set. + - The Calculation tab words a calculation problem as the attention list does, one wording per `VirtualProblemKind`. diff --git a/docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md b/docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md new file mode 100644 index 0000000..f8c54c2 --- /dev/null +++ b/docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md @@ -0,0 +1,372 @@ +# Dashboard, navigation, and historical analysis: change brief for Claude Code + +**Status:** proposed implementation brief; no application changes made by this review. +**Reviewed:** 2026-09-19, repository revision `c0f52db`. +**Scope:** overview dashboard, navigation, meter detail, energy-type pages, trends, virtual meters, and the relevant calculation services. + +## 1. Objective and review boundaries + +Make MeterVault feel like one coherent application in which users can find an option where they expect it, inspect historical data at any useful period, and understand why a particular number is unavailable. + +The central requirement is that a virtual meter combining two meters must have the same applicable consumption/generation analysis as a physical meter: period totals, history, comparisons, quality information, and costs where a valid costing rule exists. Lack of raw readings is expected for a virtual meter and must not prevent derived analysis. + +This is a source-code and product-flow review, not a browser usability test. Findings below are grounded in the checked-in Razor components, services, models, migrations, and tests. No running instance, production data, screenshots, or query timings were inspected. Layout improvements are implementation proposals; verify them in a running seeded instance before declaring completion. + +Read [CLAUDE.md](../CLAUDE.md) and [SDD.md](SDD.md), especially §§5.4–5.5, 7.4–7.5, 8, 10, and 14.1. The historical analysis work also fills existing SDD §8.3 requirements. Treat the phases below as incremental work on the existing application, not a restart of M0–M7. Proposed product defaults in this brief are explicit design decisions for this work, not claims about existing behavior. Document any necessary deviation from the SDD, including any deliberately changed reconciliation result. + +## 2. Confirmed problems and their causes + +Paths below are relative to the repository root. Method/component names are provided because line numbers will move during implementation. + +| ID / priority | Finding and user impact | Evidence / implementation starting point | +|---|---|---| +| A01 / P0 | Virtual meters are explicitly excluded from meter period analysis. A functioning combined meter is redirected to the flow page instead of getting its own history. | `src/Infrastructure/Dashboard/MeterPeriodService.cs`, `GetAsync`: returns `null` for `MeterMode.Virtual`. `src/App/Components/Pages/MeterDetail.razor`: virtual notice; `tests/Integration.Tests/MeterPeriodServiceTests.cs`, `A_virtual_meter_reports_nothing_rather_than_a_confident_zero` pins this limitation. | +| A02 / P0 | Virtual means different things in different layers. The flow service sums all upstream meters for every virtual meter; the core normalizer supports expressions; the editor exposes upstream selection without a formula editor. A subtraction formula can therefore disagree with flow. | `FlowService.GetFlowAsync`, `Core/Normalization/Normalizers/VirtualNormalizer.cs`, `Infrastructure/Normalization/MeterConfigFactory.ParseVirtual`, `Shared/MeterEditor.razor`. | +| A03 / P0 | The documented virtual read/materialization pipeline is incomplete in the inspected services. Normalization skips virtual meters, and costing reads stored consumption without resolving virtual expressions. Assigning a cost category is not a demonstrated fix for missing virtual analysis. | `Infrastructure/Normalization/NormalizationService.cs`, `RecomputeMeterAsync`; `Costing/CostService.cs`, `GetMeterCostsAsync` and `QueryConsumptionAsync`. Verify all write paths before introducing any materialization. | +| A04 / P0 | Costs can count overlapping meters more than once. Overview totals sum every meter; energy-type cost sums every meter of the type, whereas its throughput is a topology-root total. Categories deduplicate meter IDs only within that category, not parent/child coverage. | `DashboardService.ActiveMeterIdsAsync` / `TotalCostAsync`; `Pages/EnergyView.razor`, `LoadAsync`; `CostService.GetCategoryCostsAsync`. This is a structural risk; actual inflation depends on topology, memberships, and tariffs. | +| A05 / P0 | Zero, missing data, missing prices, and invalid calculations are conflated. History fills absent months with zero and hides all-zero history. Missing tariffs resolve to zero. Virtual evaluation substitutes zero for absent source timestamps and non-finite results. | `MeterPeriodService.BuildHistory`; `Core/Costing/TariffResolver.ResolveValue`; `VirtualNormalizer.Normalize`. `EnergyView.NodeValue` also falls back to zero. | +| A06 / P1 | Time ranges are inconsistent. Overview has no selector; meter detail has a fixed 12-month mini-chart; Trends defaults to 24 months with Apply; energy/Solar/consumables default to 60 months and reload immediately. “All time” is 1,200 months. | `Pages/Dashboard.razor`, `MeterDetail.razor`, `Trends.razor`, `EnergyView.razor`, `Solar.razor`, `Consumables.razor`. | +| A07 / P0 | Comparison and cutoff semantics differ. Dashboard summary requests full calendar years; breakdown ends at `asOf.AddMonths(1)`; difference truncates that span to whole months. Several pages derive today from UTC. Meter quantity SQL has no upper bound and starts at UTC Jan 1, while its cost query stops at now. | `DashboardService.GetSummaryAsync` / `GetCategoryDifferenceAsync`; `Dashboard.OnInitializedAsync`; `MeterPeriodService.MonthlySql` / `GetAsync` / `LoadCostsAsync`. Future-dated rows and local year boundaries can produce inconsistent totals. | +| A08 / P1 | History is too limited to investigate changes. Trends is total monthly cost only. Energy pages provide a flow diagram and meter list, without a historical series. Meter consumption/readings tabs show only the latest 200 records. | `Pages/Trends.razor`, `EnergyView.razor`; `MeterDetailService.MaxRows`; SDD §8.3 calls for more. | +| A09 / P1 | Overview and Trends can disagree even for matching dates: summary includes manual costs, monthly trend does not. “Latest month with data” supplies an amount without its month and derives recency only from consumption. | `DashboardService.TotalCostAsync`, `GetMonthlyTrendAsync`, `LatestMonthCostAsync`; `DashboardSummary`. A manual-cost-only instance is not handled consistently. | +| A10 / P1 | Navigation mixes analysis by energy type with specialized Solar/consumables pages. Type links open a page headed “flow,” not a general type overview. Important settings remain spread between the meter editor, Sources tab, and admin pages. Existing shortcuts help but do not provide a consistent analysis journey. | `Layout/NavMenu.razor`, `Pages/EnergyView.razor`, `MeterDetail.razor`, `Shared/MeterEditor.razor`, `MeterLinks.cs`. | +| A11 / P1 | Visual semantics vary. Meter history is a custom 110px HTML bar chart; other charts use ApexCharts. Its bars use absolute values, obscuring negative results. Chart components hardcode dark mode despite the app theme toggle. | `MeterDetail.BarStyle`; `Shared/SeriesChart.razor`, `TrendChart.razor`, `CategoryDonut.razor`; `Layout/MainLayout.razor`. | +| A12 / P1 | Labels and formatting can mislead: most cost views call `Format.Euro`, meter detail uses configured currency; `Meter.Unit` is the raw unit and is also used for normalized period results. This needs explicit handling for runtime conversions. | `App/Format.cs`; `MeterPeriodView.Unit`; `Core/Domain/Meter.cs`; `RuntimeCounterNormalizer`. | +| A13 / P1 | Rapid navigation/filter changes can be discarded by an `_loading` early return. Errors generally lack a panel-level retry state; nav DB errors silently remove the energy-type links. | `EnergyView.LoadAsync`, `Solar.LoadAsync`, `Trends.LoadAsync`, `NavMenu.LoadEnergyTypesAsync`. Reproduce stale-page behavior with delayed requests. | +| A14 / P1 | Long-history reads are aggregated directly from `consumption` in several services. Existing continuous aggregates are not a drop-in solution: the migration fixes the zone to Berlin and stores only amount sums, without coverage/quality. Flow sums all meters before filtering to the selected type. | `CostService.QueryConsumptionAsync`, `MeterPeriodService.MonthlySql`, `FlowService.GetFlowAsync`, `Persistence/Migrations/20260713094634_ContinuousAggregates.cs`. Actual performance and refresh behavior require measurement. | + +P0 means calculation/meaning must be settled before exposing more totals. P1 is required for the finished user experience, not optional polish. + +## 3. Target navigation and user journeys + +### 3.1 Sidebar and terminology + +Use one stable navigation structure: + +```text +Overview / +Analysis /trends (retain route; improve existing page) +Meters /meters +Energy types expandable group of user-defined types + /energy/{id} +Specialized views + Solar /solar + Tanks & consumables /consumables +Data import /import +Configuration existing /admin/* routes + Energy types / Tariffs / Cost categories / Connectors / Settings +``` + +- “Energy types” is the analysis entry; “Configuration → Energy types” edits definitions. Make that distinction visible in page titles and descriptions. +- Keep specialized views grouped and available with useful setup states. Do not infer electricity or oil from names or IDs; scope relevant links by capabilities and configured roles. +- Preserve existing routes and bookmarked query parameters. Extend the current helpers rather than constructing competing URLs in individual components. +- Add breadcrumbs: `Overview → `. Preserve the incoming analysis period and selected metric through drill-down and Back navigation. +- Persist expanded navigation groups and ensure the active item is visible after reload. If type links fail to load, keep the group with an error/retry affordance instead of silently removing it. +- Keep global meter search and its quick-entry actions. Show search text on desktop and an accessible icon on narrow screens. Search results should link to analysis and appropriate entry actions without forcing a trip through the meter list. + +### 3.2 Concrete discoverability requirements + +| User intention | Required path | +|---|---| +| Understand this period's usage/cost | Overview → energy-type card or cost breakdown row → scoped Analysis | +| Explain a spike | Chart bucket → finer supported period / table → meter → relevant records/events | +| Inspect a combined meter | Meter search/list/type list → virtual meter → Analysis, with source contributions visible | +| Compare two historical years | Analysis → select scope and years → previous-year overlay and comparison table | +| Add a reading or delivery | Existing meter header and list quick action; no need to locate a tab first | +| Change the source/connector | Meter → Sources → Edit connection; preserve the existing connector detour and draft | +| Understand a missing cost | Cost panel explanation → tariff editor scoped to the relevant meter/type and dates | +| Configure a virtual sum | Add/edit meter → Virtual → Sum → select source meters by name → preview | +| Configure energy topology | Energy type → Flow → Manage connections, with clear source/destination names | + +Keep the existing successful behaviors: shared meter editor, event rules, stable manual-entry keypad, tank setup shortcut, source draft restoration, one-shot URL actions, and nav refresh after energy-type edits. + +## 4. Shared period and analysis contract + +Create a reusable analysis query/result contract and period selector. Suggested names are illustrative; fit the existing project conventions. + +### 4.1 Query state + +An `AnalysisQuery` should carry scope (meter/type/category/explicit meter selection/overview), metric, local start date, local end date, bucket, comparison, and aggregation basis. Resolve relative presets against an injected `TimeProvider` and the configured instance timezone. + +- Presets: this month to date, last complete month, year to date, previous calendar year, last 12 months, last 24 months, all available history, custom dates. +- Default Overview to month to date. Default history pages to last 12 months including the current partial month. This means exactly 12 calendar buckets, not 13 or a partial extra future month. +- Display the effective dates next to the preset, plus the timezone in the range details. +- The UI's inclusive end date becomes a local-midnight exclusive upper bound on the next day. To-date presets stop at the captured current instant. Use the same resolved bounds for quantities, costs, comparisons, and exports. +- Never silently include future-dated readings in a “to date” total. A deliberately selected future range should distinguish recorded future data from actual-to-date and projections. +- All available history comes from availability metadata, not an arbitrary century-long range. +- Presets apply immediately. Custom date editing applies once both dates form a valid range, using one consistent Apply interaction across pages. +- Encode state in query parameters, e.g. `/meters/42?tab=analysis&from=2025-01-01&to=2025-12-31&bucket=month&metric=generation&compare=previous-year`. Use stable invariant tokens and localized visible labels. +- Use the URL as the authoritative state for reload/share/back. Preserve one-shot `action` handling separately; changing filters must not reopen a reading or source dialog. +- Validate bounds, IDs, enum tokens, maximum series count, and bucket/point limits. Invalid input must produce a recoverable message or documented fallback. + +### 4.2 Buckets and comparisons + +- Support day, week, month, year, and Auto where the stored data supports them. Week starts Monday in the instance timezone; preserve actual start/end dates for partial weeks. +- Auto chooses an appropriate bucket with at most 400 visible points per series. Explicit choices that exceed the limit should offer a coarser bucket rather than silently truncate. +- Compare complete periods with complete periods. For MTD/YTD, default to the same elapsed calendar portion of the comparison period, including the local time-of-day cutoff. Clamp missing dates at shorter month/leap-year boundaries and show both exact ranges. +- Distinguish actual change from projection. A “vs last year” label must not secretly compare a current-year projection with a prior-year actual. +- Show absolute difference even when percentage is unavailable. A zero or negative baseline yields “percentage not applicable” by default; do not report 0% when a denominator is absent. +- Keep signed values signed. Generation increases and consumption increases do not share the same good/bad interpretation; use metric-specific or neutral colors and explicit wording. +- Projections remain secondary, explicitly labeled, and describe their method. Suppress projections for unavailable, stale, or insufficient coverage; do not extrapolate a lone old monthly reading as though it were live data. + +### 4.3 Result and missing-data semantics + +Return structured results rather than `null`, `[]`, or `0` with no explanation. Each series needs stable meter/scope identity, quantity kind, normalized unit/currency, available range, effective requested range, calculation basis, and bucket-level values/status. + +Keep separate dimensions: availability (available/missing/partial/error), provenance (measured/manual/estimated/interpolated/derived), freshness, and price coverage. A derived value can be complete and current; these are not mutually exclusive states. + +| Situation | Display and action | +|---|---| +| Valid observations yield zero | Show numeric zero, an actual chart point, and its coverage | +| No values in selected range, older history exists | “No data for this period”; show available dates and “Go to latest data” | +| No normalized history yet | Explain the mode-specific next step; do not assume every counter requires two readings because initial-baseline behavior already exists | +| Partial source coverage | Show a partial total only if meaningful, identify missing periods/sources, exclude it from confident comparisons | +| Valid quantities, no applicable tariff | Keep quantity analysis; show cost as unavailable with a tariff action | +| Explicit applicable zero-priced tariff | Show a valid zero cost | +| Invalid virtual expression / missing dependency | Name the problem and affected source; offer Edit calculation or Open source | +| Query/refresh error | Local panel error with Retry; distinguish retained stale data from current data | +| Valid virtual meter without raw readings | Show derived analysis; raw-reading controls are not applicable | + +Do not promise an exact coverage percentage unless the stored metadata supports it. Monthly observations are not evidence of day-level completeness. Expose source resolution and known coverage bounds; where unknown, say so. Totals, charts, tables, comparisons, and exports must share these semantics. + +## 5. Virtual meters as full analysis subjects + +### 5.1 Canonical definition and editor + +Separate **calculation dependencies** from **physical flow topology**. An upstream link says where energy flows; it must not silently overwrite a configured formula. + +- Provide Sum, Difference, and Advanced expression modes in the shared editor, using source meter selectors with names, quantity kinds, and compatible units. +- Store one canonical definition: expression, referenced IDs, result kind, result unit, and supported evaluation/aggregation semantics. Derive referenced IDs from validated expressions or verify they agree exactly. +- Sum of two generation meters defaults to generation. Consumption sums default to consumption. Mixed-kind/net calculations require an explicit result meaning. Do not retain the current unconditional consumption kind. +- Validate self-reference, cycles including nested virtual meters, missing IDs, syntax, unit compatibility, and result semantics on save and again on read for legacy data. Reuse the restricted expression evaluator; do not evaluate arbitrary code. +- Give the editor a preview for the selected historical period, including per-source values and incomplete-data warnings. Show friendly source names beside any `m123` formula tokens. +- Show the formula and linked dependencies on virtual meter detail. Replace register/baseline/source-ingestion controls with appropriate calculation controls; keep any applicable note/event capability. + +### 5.2 Existing data compatibility + +The seeded `Summe Solar` meter is virtual with upstream links but no explicit formula (`Infrastructure/Import/ReferenceDataImporter.cs`). Do not break this example or existing installations configured the same way. + +1. Preserve existing explicit expressions as authoritative. +2. For expression-less virtual meters with upstream links, compatible normalized units, and unambiguous quantity kind, migrate the existing implied sum to an explicit dependency definition. Update seed creation too. +3. Preserve the topology links as topology; changes to flow links after migration must not secretly alter a saved calculation. Offer an explicit calculation edit when desired. +4. Flag ambiguous/mixed-unit/cyclic/no-source definitions as needing configuration. Do not invent conversions or overwrite metadata unrelated to virtual calculations. +5. Make migration idempotent and report converted/unresolved meter counts. Never modify raw readings. Describe any historical semantic change in release notes. + +### 5.3 Evaluation rules + +Introduce a shared Infrastructure reader (for example `MeterSeriesService`) used by meter history, energy analysis, dashboard, costing, and flow value lookup. Physical meters read normalized aggregate data; virtual meters recursively resolve dependency series. + +- Load all unique physical dependencies in bounded batches and evaluate the dependency graph in topological order. Detect cycles and enforce depth/series/point limits. +- Align source buckets by canonical instants and timezone, not exact raw timestamps or localized chart labels. +- Missing source data is unknown, not zero. Under the default strict policy, a sum bucket is complete only when all required inputs are available for that bucket. Explicitly known zero is a valid input. Preserve provenance from dependencies. +- Propagate nested failures with a useful dependency path. Non-finite arithmetic, including division by zero, produces an invalid bucket with an explanation, never a fabricated zero. +- Additive formulas such as `m1 + m2` and `m1 - m2` can roll up their evaluated base buckets. Arbitrary expressions are not necessarily additive: `sum(m1 / m2)` is not `sum(m1) / sum(m2)`. +- Define an evaluation basis for non-additive expressions and a metric-appropriate reducer (e.g. ratio of totals or weighted average). If those semantics are not supported, make that metric/granularity explicitly unavailable; do not silently change the formula's meaning when zooming. +- Preserve negative net values in history. Sankey rendering may use a separate nonnegative/directional representation, but its rendering limitation must not alter the canonical analysis value. +- Evaluation must not depend on cost-category membership. Implement read evaluation first. If costing needs materialized results under SDD §14.1, use the same evaluator with explicit dependency invalidation, rebuild rules, and tests. +- Invalidate caches after source ingestion, corrections, events, import/revert, normalization changes, definition edits, and relevant tariff edits. Avoid process-wide unbounded caches or recomputation per chart cell. + +### 5.4 Minimum worked example + +Given generation meters A and B with complete monthly data: + +| Month | A | B | Virtual Sum A+B | +|---|---:|---:|---:| +| January | 100 kWh | 150 kWh | 250 kWh | +| February | 80 kWh | 120 kWh | 200 kWh | + +The virtual meter shows 450 kWh for the two-month period, a generation label, both history points, source contribution details, and the same numbers in type analysis. It needs no raw readings and no cost category. If B is missing in February, that month is incomplete, not a confident 80 kWh. If B has an observed zero, the complete result is 80 kWh. A separate A−B meter shows −50 and −40 kWh rather than inheriting the flow service's sum. + +## 6. Totals, costs, and energy-type semantics + +Do not achieve visual consistency by making every page sum all meters. Define a shared aggregation policy and include its selected basis in results and visible explanations. + +### 6.1 Quantity totals + +- Separate consumption, generation, runtime, tank balance, and net quantities. Same energy-type membership does not guarantee addable units or independent measurement coverage. +- Default physical throughput to the appropriate non-overlapping topology roots, with consumption and generation separate. List exactly which meters contribute and which are excluded. +- A virtual view of already-counted sources is visible and analyzable but excluded from an additive portfolio total by default. Explicit selections can replace source coverage with a virtual result; they must not add both. +- Detect known overlap using topology and virtual dependencies. Do not claim completeness where overlapping measurements cannot be established from configuration; request scope configuration through a clear page action. +- Distinguish throughput from billed import and total household use. Multi-parent topology and grid-plus-solar supply do not justify summing every node as “consumption.” +- Retired meters retain their historical contribution. `IsActive` controls current operation, not erasure from historical totals; respect effective install/retire dates where reliable. + +### 6.2 Costs + +- Make cost inclusion explicit at the relevant meter/scope configuration. Default new virtual meters to analysis-only for portfolio costing so enabling their analysis does not increase the bill. +- Meter detail may show the cost of a physical or virtual scope without automatically including that scope in portfolio totals. +- Virtual costing must name its rule: tariff applied to the virtual quantity, or aggregation of already-priced independent sources. These differ when sources have different tariffs. Default to unavailable until a valid rule is inferable or configured; never sum source costs and reprice the combined quantity together. +- Handle standing charges once for the intended billing scope. Do not replicate a type/global base charge across every analytical submeter and virtual view. Preserve existing tariff precedence and document any changed billing rule. +- Add applicable price coverage to cost results. Historical tariff gaps produce partial/unavailable cost; existence of any tariff anywhere is insufficient. Missing optional credits must be distinguishable from missing required unit prices. +- For a year with tariff changes, aggregate correctly priced underlying billing periods; do not price the whole year from a July sample. Changing chart granularity must not change the total bill. Keep the existing monthly pricing convention unless a deliberate change is documented and reconciled. +- Include manual costs exactly once in matching overview, trend, and category totals. Show uncategorized contributions rather than dropping them. If categories overlap, label them as overlapping views and do not present their sum/donut as a disjoint breakdown of the bill. +- Use signed bars/tables for cost credits and negative totals. A donut is appropriate only for a nonnegative, disjoint composition. +- Make “Latest month with data” return its actual period and availability basis, including manual costs. Never silently switch every dashboard panel to historical data; offer an explicit action to open that period. +- Use configured currency consistently. Use normalized quantity units for analysis and raw units only for raw register values; resolve runtime-to-volume conversion explicitly. + +## 7. Page specifications + +### 7.1 Overview dashboard + +Use a consistent header, period toolbar, and compact coverage/freshness summary. The initial viewport should answer: what happened in this period, what changed, and where to investigate. + +1. Period cost with coverage and comparison. Show usage/generation per energy type with their own units, rather than adding unlike quantities to one total. +2. Energy-type cards with quantity, available cost, comparison, and a clear link to that type's analysis. Quantity cards work even without tariffs or categories. +3. Shared historical chart with metric toggle and previous-period overlay; the selected range applies to every analytical panel. +4. Ranked change table by category or meter: current, previous, absolute delta, percentage where meaningful. Rows link to scoped analysis with the same dates. +5. Cost composition that reconciles to the selected scope, with explicit overlapping/uncategorized handling. +6. Compact attention items only for relevant issues: missing prices, missing data, invalid virtual dependencies, stale sources. Provide a targeted action for each. + +Keep the update banner separate from analytical status. Avoid making configuration of cost categories a prerequisite for viewing valid quantities. + +### 7.2 Meter detail + +Move the tab/navigation bar directly below the identity and action header. Put the analytical content inside the default **Analysis** tab so Sources and Events do not sit below a long wall of charts. + +- Tabs: Analysis, Readings where applicable, Normalized data, Events, Tariffs, Sources for physical input meters / Calculation for virtual meters. +- Preserve old `tab=readings|consumption|events|tariffs|sources` links with a compatibility mapping. Resolve tabs by stable keys and capability, not fixed numeric indexes after conditional tabs are introduced. +- Analysis: shared period controls; selected-period quantity/cost/comparison; actual-versus-projection distinction; full-size chart; year-over-year view; accessible table; CSV export; data-quality/coverage explanation. +- History table: period, quantity, cost, comparison, quality, and coverage. Include year in date labels across multi-year ranges. Chart selection can drill to supported finer detail while retaining scope. +- Show lifecycle events and tariff changes as optional contextual markers, bounded to the selected range. A chart click should lead to records/events capable of explaining that interval. +- Raw and normalized-data tabs need server-side date filtering and pagination with a stable ordering. The latest-200 view is not full history. Explain raw retention separately from retained analytical history. +- Virtual Analysis includes source contributions and formula details. Do not show fake register totals or suggest adding a raw reading to fix virtual history. + +### 7.3 Energy-type page + +Title the page with the energy type's user-defined name. Use **Overview / History / Flow / Meters** tabs, defaulting to Overview. + +- Overview: separate appropriate quantity kinds, cost with billing basis, coverage, trends, and largest changes. +- History: shared chart/table, day/week/month/year selection, calendar-year comparison, and optional per-meter series. Offer “total” and “individual meters” views with overlap explanations. +- Flow: retain Sankey as a topology tool, using the canonical meter values. Mark inferred proportional allocations as estimates. Provide a textual/table equivalent and a connection-management entry point. +- No topology must not prevent analysis. Negative/net values remain available in History even if unsuitable for a ribbon. +- Meters: searchable list with period values, quality/coverage, physical/virtual distinction, and existing quick actions. A missing graph node must not become a fake zero meter value. +- Add appropriate links to Solar/consumables without making users rediscover a different period selector there. + +### 7.4 Analysis page (existing `/trends`) + +Replace the single monthly total-cost chart with one reusable exploration page. Scope selector: portfolio cost, energy type, category, individual meter, or explicit meter comparison. Metric selector: supported quantity kind or cost. Support at most six simultaneous meter series by default, with an explanation when the selection exceeds that limit. + +Use the same series reader, toolbar, chart, and table as meter/type pages. Do not build a second formula engine here. Comparable meter quantities must have compatible normalized units; otherwise split charts or explain why the comparison is unavailable. Category analysis is always available for cost; quantity analysis needs a single compatible quantity kind/unit. + +### 7.5 Solar and consumables + +Adopt the shared toolbar, theme, cards, history components, and missing-data semantics while retaining their specialized measures. Distinguish current tank balance/forecast from historical period totals. Show the balance's observation date explicitly; a historical date range must not label today's balance as a historical observation. + +Replace visible instructions to edit raw role tags with friendly meter-role configuration controls. Missing Solar context should identify the required role and provide a scoped setup path. Use configured units/conversions rather than assuming every generation counter is measured in kWh. + +## 8. Shared UI and interaction standards + +- Extract common components for page header, period toolbar, metric card, analysis chart/table, and availability state. Reuse MudBlazor and the existing ApexCharts integration. +- Use the existing theme for colors, spacing, borders, typography, and density. Charts must respond to light/dark changes in the current circuit. +- Chart points retain temporal identity, nullable value, and provenance; avoid reducing them to only `string Label, double Value` before rendering. Preserve numerical precision in calculation and round only for display. +- Display units in axes/tooltips and currency in cost series. Do not smooth or connect across unknown intervals. Render signed bars around a real zero baseline. +- Provide keyboard-accessible links/actions, chart table alternatives, touch-friendly controls, visible focus, and meanings that do not rely only on red/green. +- Use localized EN/DE strings in both resources and existing typed accessors. Format user data without translating meter/type names. Cover long German labels on narrow screens. +- Validate at approximately 360px, 768px, and desktop widths. Controls should wrap predictably; keep page-wide overflow out of the main layout and contain wide tables locally. +- Cancel superseded loads or use request-generation IDs so only the latest requested scope/range is committed. Publish a coherent result atomically; do not mix the previous type's chart with the next type's title. +- Expose initial loading, refresh, error/retry, and stale-but-visible states consistently. Dispose subscriptions/cancellation sources with the circuit/component. + +## 9. Data access, history resolution, and performance + +Preserve immutable raw readings and existing normalization rules, including month-label attribution, local-month splitting, swaps/resets, and import/revert behavior. Do not rescan raw readings to draw long-range charts. + +1. Centralize physical/virtual analytical reads in Infrastructure. Keep the formula algebra, compatibility checks, and reducers pure in Core where practical; keep presentation out of the domain model. +2. Select meter IDs and time bounds in SQL before aggregation. Batch dependency and tariff reads. Avoid one full scan/context/tariff load per meter per panel. +3. Audit existing continuous aggregates before switching readers: timezone, real-time/materialized behavior, backfill after historical imports, late corrections, retention, and current incomplete buckets. Their comments are not proof of freshness. +4. Add migrations for required aggregate/metadata changes rather than editing previously applied migrations. Support the configured instance timezone, not a hardcoded Berlin-only implementation. +5. Preserve coverage/provenance and available resolution through aggregation. The existing amount-only aggregates cannot answer these questions by themselves; define auxiliary summaries or sufficient aggregate fields. +6. Legacy monthly data must remain honestly monthly. Daily/week views cannot display one month-end amount as a measured daily spike. If interpolation is offered, make it explicit, mark it interpolated, and preserve monthly totals; otherwise explain the unavailable resolution. +7. Never include both a refreshed aggregate bucket and its underlying consumption in a live-tail merge. Test exact refresh boundaries and backfilled periods. +8. Bound raw/event retrieval and chart output separately. Prove history still works after raw readings are removed by the retention policy. +9. Keep current public API contracts compatible. Reuse the shared reader behind relevant endpoints where possible; add versioned/additive fields for availability rather than silently changing existing numeric response types. +10. Record representative timings and query plans before/after. Proposed review target: cached metadata plus a 10-year monthly request for 100 selected meters should complete within two seconds on documented local test hardware. Treat this as a target to measure, not a verified property. Test 1,000-meter selection/query planning and enforce output limits without a per-meter scan storm. + +## 10. Implementation phases and exit criteria + +### Phase 1 — Shared semantics and regression fixtures + +- Add frozen-clock period resolution, analysis contracts, zero/missing/partial semantics, normalized units, and explicit aggregation/cost policy. +- Add fixtures reproducing virtual absence, overlapping scopes, missing prices, historical-only data, and inconsistent cutoffs. +- Resolve proposal choices in this brief in a short implementation note; identify any incompatible legacy assumptions before migration. + +**Exit:** deterministic tests pin interval boundaries, scope membership, missing-data rules, and arithmetic semantics. No new UI claims rely on unresolved totals. + +### Phase 2 — Virtual evaluation and compatible migration + +- Implement shared physical/virtual series reader, canonical definitions, validation, editor, compatibility migration, and source contribution results. +- Remove the blanket virtual rejection from period analysis. Replace the old “virtual returns null” test with positive and error-state behavior tests. +- Route flow and cost quantity lookup through the same evaluator, keeping flow rendering separate. + +**Exit:** the worked A+B/A−B examples and seeded Summe Solar have full analysis without raw readings/category setup; nested/error cases are explained; enabling analysis does not double portfolio totals. + +### Phase 3 — History and page navigation + +- Implement shared toolbar/chart/table; upgrade meter, energy-type, and Analysis pages. +- Add period-preserving URLs, breadcrumbs, compatibility tab routing, history pagination, exports, and stale-request protection. + +**Exit:** a user can navigate Overview/type → meter → historical month and back without losing dates, and compare historical years for physical and virtual meters. + +### Phase 4 — Overview and specialized consistency + +- Rework dashboard quantities/costs/change tables, aligned manual costs, latest-data period, targeted setup actions, sidebar grouping, and Solar/consumable controls. +- Complete theme, currency, normalized-unit, localization, mobile, and accessibility work. + +**Exit:** matching scopes/periods reconcile across cards, charts, tables, and exports. Valid quantity analysis remains visible when costs are unavailable. + +### Phase 5 — Integration, performance, and documentation + +- Run targeted regression suites and then the required full build/tests. Measure query behavior with historical and synthetic data. +- Walk through the seeded application in EN/DE, light/dark, and mobile/desktop. Capture representative before/after screenshots and any remaining limitations. +- Update SDD/CLAUDE descriptions that currently claim unsupported virtual behavior or no longer describe the navigation/calculation model. + +**Exit:** all acceptance scenarios below pass or have an explicitly documented blocker. Do not mark the feature complete after only changing menus or removing the virtual `return null`. + +## 11. Acceptance scenarios and validation + +| Scenario | Required evidence | +|---|---| +| Two-source virtual generation sum | 100+150=250 and 80+120=200 by month; 450 total; generation units; physical and virtual pages/type history agree | +| Missing versus zero source | Missing B makes the bucket partial/unavailable; observed B=0 yields a complete sum | +| Difference and nesting | Negative A−B plots below zero; nested dependencies resolve once; cycle reports a named dependency error | +| Invalid arithmetic | Division by zero and unsupported non-additive rollup produce explanations, never zero or infinity | +| Legacy virtual configuration | Seeded Summe Solar acquires a compatible definition; migration rerun changes nothing; existing formulas retain precedence | +| Overlapping topology | Parent 300 and child 100 display 300 for the non-overlapping parent scope, with 100 as a breakdown, not 400 | +| Virtual overlap | Sources 100+150 and virtual 250 remain a 250 portfolio quantity where that is the selected coverage, not 500; costing has equivalent explicit coverage | +| Missing versus free tariff | Valid quantity with missing required price has unavailable cost; explicit zero tariff has valid zero cost | +| Cost stability | Manual costs appear once; standing charges follow the chosen billing scope; changing chart bucket does not reprice annual totals from one sampled tariff | +| Historical-only instance | Current period explains no data and offers actual available dates; selecting historical year shows quantities/costs for physical and virtual meters | +| Manual-cost-only instance | Overview, trend, category breakdown, and latest-data month agree without requiring a meter | +| Local-time boundaries | Frozen clock tests at local New Year, Berlin spring/fall DST, leap day, shorter months, and a zone behind UTC; from/to are half-open consistently | +| Future rows and partial periods | To-date totals exclude future data; comparisons state actual matched dates; projections are explicitly separate | +| Zero/negative history | Legitimate all-zero year remains visible; negative net history is signed and percentage rules are consistent | +| Monthly legacy resolution | Monthly import stays monthly or explicitly interpolated; daily chart never invents measured detail | +| Retained history | Charts still work after old raw readings are absent; raw-record tab explains retention | +| Rapid filter/navigation change | Delayed first request cannot overwrite the later selected type/range; reload/back preserves URL state | +| Compatibility actions | Existing reading/event/edit/source URLs still open the intended action once; connector detour preserves typed draft and return context | +| UI consistency | Theme toggle updates charts; EN/DE resource tests pass; narrow-screen controls and keyboard/table alternatives work | +| Import/correction freshness | Import, revert, reading deletion, swap/reset, and tariff/definition edits refresh affected physical/virtual history without duplicates | + +Use the existing suites as starting points: + +- `tests/Core.Tests/VirtualMeterTests.cs`, `ExpressionEvaluatorTests.cs`, and relevant normalization tests. +- `tests/Integration.Tests/MeterPeriodServiceTests.cs`, `FlowServiceTests.cs`, `CostSetupTests.cs`, and costing/reconciliation suites. +- `tests/Integration.Tests/DashboardRenderTests.cs`, `LocalTimeEntryTests.cs`, and `Localization/StringResourceTests.cs`. + +Add tests around substantive data behavior and navigation state; do not rely on snapshots of markup alone. Server-render tests do not prove interactive ApexCharts updates, browser history, or responsive usability. Add meaningful interaction coverage using the repository's available tooling, or record a manual browser checklist when no suitable harness exists. + +Commands for the implementing agent: + +```powershell +dotnet build +dotnet test tests/Core.Tests +dotnet test tests/Integration.Tests +``` + +Integration tests need Docker/TimescaleDB. Report actual commands/results and prerequisites that prevented execution. Preserve golden CSV reconciliation unless a deliberate correctness fix is described with old/new values and a focused regression test. The source review that produced this brief did not run these tests. + +## 12. Delivery requirements for Claude Code + +Deliver working code in reviewable phases, necessary migrations, both language resources, substantive tests, and updated documentation. Keep the current stack and user-defined energy types. Do not rewrite ingestion, introduce a new frontend, or change raw-reading history to make charts look correct. + +The final implementation report should state which findings were fixed, how physical and virtual results now agree, which cost/aggregation decisions were applied to existing data, what was tested, and any remaining limitations. Include screenshots of the new Overview, physical/virtual meter history, and energy-type history at desktop and mobile widths. + +**Definition of done:** a user can find the correct action without guessing which page owns it, examine the same selected period consistently across the application, analyze a valid combined virtual meter as fully as its compatible physical inputs, and distinguish a true zero from unavailable or incomplete information. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md new file mode 100644 index 0000000..adeabfc --- /dev/null +++ b/docs/RELEASE_NOTES.md @@ -0,0 +1,221 @@ +# Release notes + +User-visible changes per release. Earlier releases are described in the git history and in the README's upgrade +sections. Decision ids (D-nn, A-nn) refer to [`ANALYSIS_IMPLEMENTATION_NOTE.md`](ANALYSIS_IMPLEMENTATION_NOTE.md). + +## 0.4.0 — Dashboard, navigation and historical analysis (unreleased) + +This release rebuilds how MeterVault analyses and prices data, and the pages that show it. Every page now works on +the same selected period and the same numbers. A virtual (calculated) meter can be analysed like a physical one. A +true zero, missing data, data that exists only per month, and a missing price are always told apart. + +**Some figures change on purpose.** Most visibly, an energy type's bill now counts its grid meter, not every meter +that exists. [Changed figures](#changed-figures) lists every deliberate change. The seeded demo's yearly costs now +match the spreadsheet: 2025 comes to 7,907.65 € against the sheet's 7,907.64 €. The seeded "this year" cost for 2026 +was 4,402.19 € in 0.3.0, which priced Haus + Netz + Auto and water at 5 €/m³. It is now 2,940.19 €, the sheet's +figure. + +### Upgrading + +Back up the database (`pg_dump`) first. + +- **The first start rebuilds all analysis data** (normalization revision 3): + - For every meter it rebuilds consumption and the new day and month rollups plus their coverage. It records the + state of each meter. + - This runs **before the web server listens**. The app is unreachable meanwhile, and Compose may report the + container unhealthy. Let it finish. + - The time grows with the number of raw readings. One meter took about 0.1 s with monthly readings, ~0.6 s with ten + years of daily readings (3,700 readings), and ~1.4 s with a year of hourly readings (9,300 readings). + - A synthetic 1,000-meter × 10-year instance (1.34 M readings) took 5.6–6.5 minutes (338–392 s) on a Ryzen 9 + 9950X3D. That run was preliminary, on a busy machine. It is about 20 % slower per meter than the 0.3.0 rebuild, + because rollups and coverage are written too. + - Progress is logged every 100 meters. A meter that fails is logged, kept pending and retried at the next start. + A meter whose stored consumption is older than its oldest remaining reading is skipped and logged, so its + history is not truncated. + - Until a meter is rebuilt, its pages say "analysis being prepared", never "no data". +- **Virtual meters without a formula** (such as a seeded *Summe Solar* from an earlier version) are converted at + startup. When their incoming links name meters of one unit and kind, the implied sum is stored as an explicit + formula (D-28). The log lists the converted meters, and the meters that still need configuration because their + links are ambiguous, in mixed units, or loop. The conversion runs once; a rerun changes nothing. From then on, links + are topology only and never change a calculation. +- **The migration drops the unused continuous aggregates** and their hourly refresh jobs. It also deletes consumption + rows stored for virtual meters, which nothing read (D-17). +- **Check the attention items** on the Overview after the first start. They name missing prices with a direct "Add + tariff" link, invalid calculations, stale live sources, rows dated after now, and meters that may be counted twice. +- **Rolling back** to 0.3.0 works. The old version ignores the new tables and never read the dropped aggregates. + Consumption stays as 0.4.0 booked it until each meter next ingests a reading. Stored virtual formulas remain, and + 0.3.0 goes back to summing links. + +### What's new + +**Navigation.** +- The sidebar has a fixed structure: Overview, Analysis, Meters, an **Energy types** group, **Specialized views** + (Solar, Tanks & consumables; always listed, with a setup hint when not configured), Data import, and + **Configuration**. +- "Configuration → Energy types" edits the definitions; the Energy types group is for analysis. +- Expanded groups are remembered, and the current page's group always opens. If the energy types cannot be loaded, + the menu shows an error with Retry instead of silently dropping them. +- Breadcrumbs (Overview → energy type → meter) keep the selected period, and so does Back. +- "Find a meter" opens the meter's analysis with the current period and still offers quick entry. It is a text + button on desktop and an icon on phones. + +**One period everywhere.** +- Every analysis page has the same toolbar: this month to date, last month, year to date, previous year, last 12 or + 24 months, all history, or custom dates with one Apply. +- It also offers a bucket size (automatic, day, week, month, year) and a comparison: previous period, same period + last year (the default), or any calendar year. +- The effective dates are shown next to the choice, with the time zone. The selection lives in the address, so + reload, Back and shared links reproduce the page. +- Rapid clicks can no longer leave one page showing another selection's data. + +**Overview.** +- It shows one selected period (default month to date): + - the cost, split into metered use, standing charges, manual costs and feed-in credit; + - a card per energy type with its quantities in their own units, its cost and billing basis, the change and + freshness; + - a history chart with a table view; + - "What changed", by category or by meter; + - the cost composition; + - attention items, each with one targeted action. +- Changes are compared only over the part both periods cover, and the page states both date ranges. +- When the period has no data, the page names the dates that do have data and offers "Go to latest data". It never + silently switches to an older month. + +**Analysis page** (`/trends`, formerly the cost trend). +- Explore everything, one energy type, a cost category, one meter, or up to six meters side by side, by quantity or + by cost. +- Compare calendar years with an overlay and a comparison table. Click a bar or a row to drill into a finer period. +- Export exactly what is shown as CSV. + +**Energy type pages** are titled with the type's own name and have four tabs: +- **Overview:** measures such as total use, grid import, generation and runtime, each in its own unit and never added + across units; the cost with its billing basis; coverage; the largest changes. +- **History:** the total, or up to six individual meters with an explanation of how each one counts. +- **Flow:** the Sankey, now using the same values as every other page. Calculated and estimated connections are + marked. It comes with a table version and **Manage connections**. +- **Meters:** each meter's value for the period and its data quality. + +**Meter page.** +- The tabs sit directly under the header: Analysis, Readings, Normalized data, Events, Tariffs, Sources. A calculated + meter has Calculation instead of Sources. +- The Analysis tab shows: + - the period total with its unit and status, and the cost with its rule, or the reason it has none; + - the change against the comparison period; + - a labelled projection, where there is enough data for one; + - a full chart with the previous-year overlay, and a table; + - a "Data quality and coverage" section; + - events and tariff changes in the range. +- The record tabs page through the **whole history** (100 rows at a time, filtered by the selected dates), no longer + just the latest 200. Rows dated after now are marked. +- Existing links such as `?tab=consumption`, `?tab=readings&action=reading` and `?tab=events&action=swap` still open + the intended tab and dialog once. + +**Virtual (calculated) meters.** +- Create them with **Sum**, **Difference** or a **Formula** over other meters, picked by name. A live preview of the + selected period shows every source's values and flags incomplete months. +- The result kind (consumption, generation, net or indicator), unit and cost rule are stored with the formula. +- A virtual meter gets the same analysis as a physical one, plus "Source meters" showing each source's contribution. +- **The rules:** + - A missing source month makes the result "no data" for that month, never a silent zero. + - An observed zero is a real zero. + - A division by zero or a loop is reported, and names the meters involved. + - Differences stay negative. +- New calculated meters are *analysis only*: they never add to a type's totals or the bill. The "Always count" option + lets one replace its sources instead. + +**Solar and Tanks & consumables** use the same toolbar, cards and charts. +- **Solar** works out self-consumption, feed-in, site use, savings and autarky from the meters' roles. For a missing + role it shows a setup card with candidate meters instead of raw role tags. +- **Tanks** keep "Last dipstick (date)" apart from "Estimated now". A past period shows the contents at its end, not + today's. The forecast is a labelled projection, hidden when the dipstick is older than 60 days. + +**Tariffs and configuration.** +- An "Add tariff" link from a missing price opens the tariff editor once, prefilled with the scope, component and + first uncovered month. +- The unit is checked against what it prices: a wrong unit or currency blocks the save. +- A new tariff needs a value; a typed 0 is a deliberate free period. +- The editor notes that Bonus, Discount and Tax are stored but **not applied** yet. +- Meter roles have friendly names and one-line meanings. A role is unique per energy type, and saving it names the + meter it moves from. +- The Settings page shows the analysis data state and that raw retention is not enforced. + +**Also:** +- CSV export of any analysis view (`/export/analysis.csv`): statuses, provenance, costs and comparison values; unknown + values are empty cells, never 0. +- The light/dark choice persists across reloads and language switches, and charts follow it immediately. +- Visible keyboard focus, and tables for every chart. +- MudBlazor's own labels are in German too. +- Pages work at phone width. +- Amounts use the configured currency (`MeterVault__Currency`) instead of a hard-coded €. +- Deleting a meter or an energy type also deletes the tariffs scoped to it. +- JSON export/import now carries meter connections, re-links virtual formulas to the new meter ids, and no longer + restores the tariffs of deleted meters onto other meters. + +### Changed figures + +Every change below is deliberate. The golden spreadsheet reconciliation (consumption of all four sheets, Netz +Einsparung) is unchanged. The seeded yearly bill matches the sheet's `Jahreskosten` within ±0.02 € for 2022, 2025 and +2026. It differs by 3.78 € (2023) and 0.46 € (2024) only because the sheet multiplies by unrounded prices. + +| Area | 0.3.0 | 0.4.0 | +|---|---|---| +| What an energy type's bill counts | Every meter's cost was summed: Haus + Netz + Auto for the seeded Strom | The type's grid import meter when it has one, otherwise its household use. Submeters are breakdowns; generation is never billed. Seeded Strom is Zähler Netz × price, like the sheet (2025: 4,742.64 €) (D-22, D-34) | +| Feed-in credit | Credited on all generation | Only on a meter with the grid-export role, at the feed-in price (D-34) | +| A subsection with its own meter price | Added on top | Billed at its own price and taken out of the meter above it; quantities unchanged (D-35, A-19) | +| Missing tariff | Cost 0 | "Not priced (no tariff)" with an Add tariff action; a hole in a price history is a price gap (unavailable). The quantities stay visible. An explicit 0 tariff is still a valid zero (D-38) | +| Standing charges | Per meter, per month that had readings, and copied onto every meter of the type | Per local day over the scope's service period (including reading gaps), **once per scope**. Type and global charges are their own rows; meter fees stay on their meter (D-40, A-18) | +| Price of a longer bucket | Month buckets used the price of the 15th, year buckets the price of 1 July | Every bucket is priced month by month at the price of the 15th, so a year equals the sum of its months and changing the bucket never changes a total (D-36) | +| Months in which the billed grid meter was not yet (or no longer) in service | — (0.3.0 summed every meter) | Unavailable rather than free while use was measured, with an attention item naming the grid meter and the months (A-17) | +| Tank, runtime, direct-delta or instant-rate readings more than a month apart (a tank dipped every few months, quarterly burner hours) | Booked whole in the month of the later reading, with zeros in between | The months in between read "only coarser data" (not zero). A year or longer bucket is priced when all its months share one price; otherwise it is unavailable with an attention item (A-16) | +| Manual costs | Counted in the Overview but not in the trend; a cost dated later this month counted at once | Counted once, on their start day, when that day has come, everywhere: Overview, Analysis, categories, export (D-41) | +| Cost categories | Sum of their member meters' costs | The priced non-overlapping cover of their members plus their manual costs. Seeded Strom = Netz × price. A category overlapping another is shown as a view, apart from the composition. A category whose members price nothing says so (D-42, A-22) | +| Virtual meters | No analysis; the flow summed incoming links, ignoring any formula | Full analysis from the stored formula; the Sankey uses the same values (D-27, D-30) | +| Summe Solar and other generation sums | — | Analysed as generation (seeded 2025: 4,750 kWh = Solar 1 3,123 + Solar 2 1,627) and **not costed**, because generation is never billed (A-15) | +| Readings at exactly midnight | Booked in the following day | Booked in the day they close (D-11) | +| "Last 12 months" | 13–14 buckets, including a partial future month | 12 calendar buckets ending with the current month; actuals stop at now (D-02) | +| Rows dated after now | Counted in "to date" totals | Excluded and shown as "recorded after now", e.g. a sheet row labelled the current month or a future-stamped reading. A day that holds such a row reads partial (D-04, A-14, A-20) | +| Overview "this month / this year" | Current month and year against the complete previous ones | The selected period against the same elapsed part of the comparison period, measured over what both cover (D-07) | +| "Latest month with data" | An amount without its month; consumption only | The month and its basis (meter data, manual costs or both) (D-19) | +| Percentages | A negative baseline was divided by its absolute value | "Not applicable" for a zero or negative baseline; the absolute difference is always shown (D-08) | +| Meter dates | — | Outside its install and retire dates a meter counts as a known zero; retired meters keep their history (D-24) | +| Currency | € hard-coded in most views | `MeterVault__Currency` everywhere; a tariff in another currency is reported as not fitting, never converted (D-43) | +| Continuous aggregates | Refreshed hourly, read by nothing | Dropped; rollup tables are written with each recompute (D-12, D-17) | +| Seed | — | Adds the water price of 7.00 €/m³ from 2026-01-01, and stores Summe Solar's formula (`m4 + m5`, generation, not costed). This affects new seeds; existing seeded instances get the formula through the startup conversion (D-44) | + +### REST API + +Every existing field keeps its name and type. What changed is only added as new fields. The numbers follow the new +engine, as listed above: actuals stop at now, virtual meters are evaluated, and costs are the bill's. + +- **`GET /api/v1/consumption`:** + - New fields: `status`, `issue`, `kind`, `unit`. + - A month without data is left out instead of reported as 0. + - `from`/`to` with a UTC offset are accepted; they used to fail with a server error. + - Virtual meters return evaluated values. +- **`GET /api/v1/cost`:** + - New fields: `costStatus` (`Priced`, `Partial`, `NotPriced`, `PriceGap`, `UnitMismatch`), `costAvailability` (the + state of the quantities behind the cost, e.g. `Unresolved`, `Invalid`), `costRule`, `notCosted`, `missingPrices[]` + (component, reason, scope, first and last month, tariff, unit issue, credit), `status`, `issue`, `kind`, `unit`. + - `cost` stays numeric and is 0 when nothing could be priced; check `costStatus` and `costAvailability` before + trusting a 0. + - **Behaviour change:** generation and runtime meters, indicators and calculations that cannot be evaluated now + report `costStatus: NotPriced`, with `notCosted` giving the reason. They used to report `Priced`, and a generation + meter could carry a negative feed-in cost (A-21). +- **`GET /api/v1/dashboard/summary`:** + - New fields: `deltaPercentApplicable` per KPI, and `latestMonth` `{period, basis}`. + - The month and year windows are unchanged (the calendar month and year to now, against the whole previous ones), + but the values are the new bill. `deltaPercent` is 0 when not applicable. + +### Known limitations + +- **Raw retention is not enforced** (D-57). `MeterVault__RawRetentionDays` is shown but nothing deletes readings, + because every recompute rebuilds a meter from its readings. +- **Monthly data is never interpolated to days** (D-57). A day or week view of monthly data says "only coarser data" + and offers the monthly view. +- **Bonus, Discount and Tax tariffs are stored but not applied** (D-57). +- **Every live reading recomputes its meter in full** (D-57). That is fine for monthly and daily meters, but costs + about 1.4 s per reading for a meter with a year of hourly data, and grows with history. +- **Months cannot switch billing basis:** the billing basis (grid meter or household use) is chosen per energy type + for all time (A-17). +- **Batteries are not modelled.** Without a grid-export meter, Solar's feed-in is calculated, and labelled as such. +- **No CSV export on the Solar page**, because the export has no derived measures. diff --git a/docs/SDD.md b/docs/SDD.md index aa54337..8e4ba9c 100644 --- a/docs/SDD.md +++ b/docs/SDD.md @@ -5,6 +5,13 @@ > **What this is:** a self-hosted, local-first energy & utility metering platform that pulls meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading with a timestamp, and turns it into cost dashboards. Not limited to electricity/water/oil — energy types are user-defined. > > **Status:** design spec, pre-code. This document doubles as the build brief for Claude Code. +> +> **Implementation status (0.4.0):** M0–M7 are implemented, and the dashboard/analysis rework +> ([`DASHBOARD_ANALYSIS_CHANGE_BRIEF.md`](DASHBOARD_ANALYSIS_CHANGE_BRIEF.md)) replaced the aggregation, virtual-meter, +> cost and page model. Its decisions are numbered D-01 – D-58 and A-01 – A-39 in +> [`ANALYSIS_IMPLEMENTATION_NOTE.md`](ANALYSIS_IMPLEMENTATION_NOTE.md). The original design text below is kept. +> Wherever the system now works differently, a **Deviation** or **Current behaviour** block says so in place (list: +> D-58). The results are in [`ANALYSIS_REPORT.md`](ANALYSIS_REPORT.md). --- @@ -104,6 +111,14 @@ Early rows (1997–2004) only carry **deliveries** (no burner hours — tracking | FR-18 | **Deploy** via Docker Compose (app + TimescaleDB); Unraid template; healthcheck endpoint; backup guidance. | | FR-19 | **Auth**: optional local accounts *and* reverse-proxy trust (honour `X-Forwarded-User` behind Authelia/Traefik). | +> **Where the implementation differs (D-58):** +> - **FR-9:** prices are monthly (the 15th of each local month); there is no day-accurate proration mode. Bonus, +> discount and tax are stored but not applied (§7.5, D-57). +> - **FR-11:** rollups are by local day and month. Weeks and years are built from them, and there is no hourly level +> (§5.4). +> - **FR-12:** the Overview shows one selected period instead of fixed today/month/year cards (§8.1). +> - **FR-16:** raw retention is shown but not enforced (§5.5). + --- ## 4. Architecture & tech stack @@ -125,6 +140,10 @@ Early rows (1997–2004) only carry **deliveries** (no burner hours — tracking > The **domain model and DB schema are UI-agnostic.** If a future maintainer swaps Blazor for an SPA, everything from §5–§7 and the REST API in §9 is reusable. +> **Deviation (D-17, D-58):** continuous aggregates are not used. They were dropped in favour of rollup tables that the +> recompute writes (§5.4). Dapper serves the hot-path reads of those tables (`AnalysisQueries`). No hosted service +> refreshes aggregates. + ### 4.2 Components & data flow ``` @@ -146,6 +165,13 @@ Early rows (1997–2004) only carry **deliveries** (no burner hours — tracking Ingestion writes **raw `reading`** rows. A normalization step derives **`consumption`** (append-only, base unit). Continuous aggregates roll consumption up to hourly/daily/monthly/yearly. The cost engine joins aggregates with time-ranged tariffs. The dashboard and API read aggregates + cost views (never scan raw for charts). +> **Current pipeline (D-12 – D-17, D-27, D-34):** the recompute derives `consumption`. In the same transaction it +> writes per-meter rollups by local day and local month, plus coverage runs, all in the configured zone (§5.4). One +> shared **analysis reader** (`AnalysisReader`) reads those rollups. It evaluates virtual meters on read (§7.4) and +> classifies per-type totals. One **cost engine** (`CostReader`) prices the bill from those quantities and the +> time-ranged tariffs (§7.5). The pages, the REST API and the CSV export read only these two readers. None of them +> scans `reading`. + --- ## 5. Data model & database @@ -168,6 +194,18 @@ Design principles: raw readings are immutable audit truth; everything derived (c - `import_batch` — provenance + revert for CSV/manual bulk loads. - `app_setting` — currency, locale, timezone, retention, fallbacks. +Added since this sketch (see §5.4 and the note, D-12): + +- `meter_link` — directed flow topology (`from → to`: the downstream meter is a subsection of the upstream one; + several parents are allowed). It is topology only: it never defines a virtual meter's calculation (D-25). +- `consumption_rollup` / `consumption_rollup_month` — per-meter sums of `consumption` by local day and local month, + with per-provenance amounts, row count, flags and the latest interval end. +- `meter_coverage` — per-meter coverage runs with their resolution class. +- `meter_rollup_state` — the revision, zone, normalized unit and kind each meter's analysis rows were built with. + +> **Deviation (D-57):** the principle "long-horizon retention is served by aggregates, not by keeping every raw row +> forever" is not realised. Raw readings are kept indefinitely (§5.5). + ### 5.2 Measurement modes (`meter.mode`) | Mode | Meaning | Consumption derived by | @@ -183,6 +221,10 @@ Design principles: raw readings are immutable audit truth; everything derived (c ### 5.3 Schema sketch (PostgreSQL + TimescaleDB) > Illustrative DDL; EF Core migrations own the relational tables, and **raw-SQL migrations** own the Timescale-specific DDL (`create_hypertable`, compression, continuous aggregates, retention). Timescale objects are *not* expressible through EF's model builder. +> +> *Current:* the Timescale DDL in use is the two hypertables (`reading`, `consumption`) and raw compression. The +> continuous aggregates were dropped by the `AnalysisRollups` migration (D-17), and no retention policy exists (D-57). +> The rollup, coverage and state tables are ordinary EF-owned tables with `ON DELETE CASCADE` to `meter` (D-12). ```sql CREATE TABLE energy_type ( @@ -353,6 +395,48 @@ CREATE TABLE app_setting ( ### 5.4 Continuous aggregates & cost view +> **Deviation (analysis rework, `docs/ANALYSIS_IMPLEMENTATION_NOTE.md` D-10 – D-17, D-36, D-58):** the continuous +> aggregates below were Berlin-only and materialized-only. They were never backfilled after historical imports, and +> no reader used them. The `AnalysisRollups` migration dropped them together with their refresh policies. The SQL +> below is kept as the original design. What replaced them: +> +> - **Rollup tables** (plain tables, FK to `meter` with `ON DELETE CASCADE`), all keyed by local dates of the +> **configured** zone: +> +> | Table | Key | Holds | +> |---|---|---| +> | `consumption_rollup` | `(meter_id, day, kind)` | amount; measured/manual/imported/estimated shares; row count; flags (baseline delta, divided); latest interval end | +> | `consumption_rollup_month` | `(meter_id, month, kind)` | the same per local month (read for month and year buckets) | +> | `meter_coverage` | `(meter_id, span_from)` | coverage runs: span, resolution class (≤ 1 h, ≤ 1 day, ≤ 7 days, ≤ 1 month, coarser), whether divided at months, gap reason, last interval start | +> | `meter_rollup_state` | `(meter_id)` | normalization revision, zone, normalized unit and kind, build time | +> +> - **Written by the recompute, by diff, in its transaction.** `NormalizationService.RecomputeMeterAsync` stages them +> through `AnalysisDataWriter` in the same transaction as the meter's `consumption`. Only changed rows are touched. +> Import, revert, events, manual readings, live ingestion and meter edits all reach the next read without a refresh +> job. No cache sits in between. +> - **Intervals.** Every normalized row carries its source interval (EF-ignored, D-10), so coverage is captured while +> normalizing instead of being guessed from sums. A non-label row that ends exactly on a local midnight is stamped one +> second earlier, inside the day it closes (D-11). +> - **Bucket status** (D-14) comes from coverage, never from the amount: +> - `Available`: covered. A zero is a true zero. +> - `Partial`: only partly covered. +> - `Missing`: nothing covers the bucket. +> - `Unresolved`: covered only by an undivided interval that crosses the bucket edge, e.g. monthly data asked by day. +> - `Invalid`: a calculation failed (virtual meters, §7.4). +> - `Pending`: the rollups are being rebuilt. +> A to-date read caps coverage at "now". Rows whose interval closes after now are reported, never counted (D-04, +> A-04, A-05, A-14, A-20). +> - **Reads.** Month and year buckets read the month table. Days and weeks read the day table. At most two partial +> edge days per range come straight from `consumption`. Every request makes one query per table, and the point and +> series limits are checked before any SQL runs (D-15). +> - **Rebuild.** Normalization revision 3 (D-16) rebuilds consumption, rollups and coverage for every meter at the +> next start (`NormalizationUpgrade`). It rebuilds again whenever the revision or the configured zone differs from +> `meter_rollup_state`. Until then a meter reads as "analysis being prepared" (`Pending`), never as "no data". +> +> **Cost is not a view either.** The cost engine (`CostReader`, §7.5) prices each bucket month by month, at the price +> valid on the 15th of each local month (D-36), so the bucket size never changes a total. There is no day-accurate +> proration mode (§14.3). A price change inside a reading interval longer than a month is reported, not guessed (A-16). + ```sql -- Daily normalized consumption per meter (local-tz buckets) CREATE MATERIALIZED VIEW consumption_daily @@ -390,6 +474,20 @@ Normalized/rolled-up volumes are tiny regardless: daily consumption = 1000 × 36 > Design conclusion: 1000 meters × 50 years is comfortably within TimescaleDB on modest hardware **provided** dashboards read aggregates and raw retention is bounded. Don't let the UI scan `reading` for charts. +> **Deviation (D-57, a documented limitation):** raw retention is **not enforced**. `MeterVault__RawRetentionDays` +> (default 1095) is shown, but nothing deletes readings. Every recompute rebuilds a meter's consumption from the +> readings that remain, so dropping old readings would destroy analytical history. Bounded raw retention first needs a +> recompute that can start from stored consumption. `/admin/settings` shows "Not enforced" with that reason, and the +> meter page's Readings tab explains it. +> +> **History reads the rollups** (§5.4 deviation): `consumption_rollup_month` for month/year buckets and +> `consumption_rollup` for day/week buckets. `consumption` itself is read only for at most two partial edge days per +> range and by the meter page's Normalized data tab. No chart reads `reading`. It is read by the paged Readings tab +> (100 rows per page, keyset-ordered, date-filtered, D-50), by the manual-entry checks, and for freshness: the latest 20 +> reading times per meter (D-18). That freshness query has no time bound yet (§13, report). `consumption` and the +> rollups are the analytical history. Performance was measured against a synthetic 1,000-meter × 10-year dataset +> (`docs/ANALYSIS_REPORT.md`, §13). + --- ## 6. Ingestion @@ -435,46 +533,385 @@ Imported monthly tables keep the golden fixtures reconciling (§13). A row label Every reader that buckets consumption by month or day (cost, trends, solar, consumables, flow, meter detail) buckets in the configured instance timezone — the same zone the division uses — never a hard-coded one, and starts and ends requested periods at local midnight. The zone id is normalised to its IANA form, and one unknown to .NET or PostgreSQL is reported at startup. Because consumption is derived, a change to these rules is applied to stored data at startup: `app_setting.normalization_revision` and `normalization_zone` record the rule revision and zone the stored series was built with, and every non-virtual meter is recomputed when either differs (the first run also flags month rows of earlier monthly imports, identified from each batch's stored mapping; a batch whose dates were auto-detected counts as monthly when all its rows sit on the 1st across at least two months, which is logged; if the flagging fails, nothing is rebuilt and the upgrade is retried at the next start). A meter whose rebuild fails is logged and listed in `normalization_pending`, retried at the next start, and never stops the application from starting. +> **Current (normalization revision 3, D-10, D-11, D-16):** +> - Every normalized row carries its source interval, so coverage and resolution can be recorded (§5.4). +> - A reading that closes exactly at a local midnight is booked in the day it closes, not the next one. +> - The startup rebuild also writes the rollup and coverage tables, and records each meter's `meter_rollup_state`. +> Virtual meters store no consumption: the migration purged their rows, and a meter that becomes virtual is purged +> on its next recompute. +> - A meter whose oldest consumption predates its oldest reading or event is skipped and logged, so its history is not +> truncated. +> - Month division still applies only to cumulative and generation counters. Tank, runtime, direct-delta and +> instant-rate intervals are booked whole. Where such an interval crosses a month edge, the months on either side are +> `Unresolved` and only coarser buckets are resolved (D-14, A-16). + ### 7.2 Runtime → consumption (burner) For `runtime_counter`: `amount = Δhours × rate`. `rate` comes from the linked `tank`: `fixed` (nozzle spec, L/h) or `empirical` (`Δlevel ÷ Δhours` measured between deliveries/level reads — reproduce the spreadsheet's 1.87/1.94/2.92 … behaviour). Expose both; default empirical when level data exists, else fixed. ### 7.3 Consumable/tank balance & forecast `balance(t) = Σ deliveries(≤t) − Σ consumption(≤t)`, reconciled to physical `tank_level` events when present (cm → litres via calibration). Forecast to empty from a trailing consumption rate (e.g. last-30-day L/day) → `Vorraussichtliches Ende`. Surface low/reorder thresholds. +> **Current (D-54, `TankLevels`):** the last dipstick ("Last dipstick: value on date") is kept apart from "Estimated +> now (incl. deliveries since)", which does not deduct use since the dipstick. For a period that ended before now, +> the Consumables page shows the contents at the period's end, never today's balance. The forecast is a labelled +> projection. It is hidden when the dipstick is older than 60 days, when too little time has passed, or when no use +> was measured. Deliveries before a tank's first level open a coverage gap (D-13). + ### 7.4 Virtual meters For `virtual`: evaluate `config.expression` (whitelisted, sandboxed — a small safe expression evaluator over referenced meters' consumption/generation series), e.g. `self_consumption = generation − grid_feed_in`, `savings = self_consumption * unit_price`. Persist results to `consumption` (or compute on read — decide per §14). This is how PV self-consumption/savings and net figures are modelled without hardcoding. +> **Deviation (D-25 – D-33, D-39, A-08, A-12, A-15, D-58):** +> +> - **Canonical definition in `meter.meta`:** `expression`, `referencedMeterIds` (always derived from the expression +> and rewritten on save), `resultKind`, `resultUnit` and `costRule`. The result kind is one of `consumption`, +> `generation`, `net` or `indicator`. Save writes the effective (inferred) kind, unit and cost rule, so readers never +> re-infer them (A-08). Topology links (`meter_link`) never define or change a calculation. `meter_source` rows of +> type `virtual` are not used for this. +> - **Formula:** +> - Grammar: `+ − * /`, parentheses and numbers, parsed to an AST (`FormulaParser`), at most 2,000 characters and +> nesting depth 64. +> - References are `m`. Any other identifier is an error; it is never read as 0. +> - The old string evaluator and `VirtualNormalizer` were removed. +> - **Validation** (`VirtualValidator`), on save and again on read for legacy data, covers: +> - syntax; +> - unknown or self references; +> - loops through nested virtual meters, reported with their path; +> - kinds and units: `+`/`−` need the same unit and kind or a declared `net`; meter × or ÷ meter needs a declared +> unit and kind `indicator`. Indicators are non-additive, never totalled and never costed. +> - **Evaluated on read, never materialized** (`VirtualEvaluator`, through `AnalysisReader`): +> - Evaluation runs per bucket from the sources' rollups, in dependency order. Each physical source is read once, +> however deeply it is nested. +> - Coverage is the intersection of the sources' coverage, and the resolution is the coarsest among them. +> - Strict: a missing source bucket makes the result missing and names the source. An observed zero is a valid input. +> - A non-finite result (division by zero) is `Invalid` with the reason. So is a loop, which is reported with its +> dependency path. +> - A period total is the formula over the sources' totals across their joint coverage. For a linear formula without +> a constant this equals the sum of its buckets; any other formula (for example a ratio) is marked non-additive, +> and its total is the ratio of totals. +> - The result carries every source's series (the "source contributions" on the meter page). +> - Nothing is written to `consumption` for a virtual meter. This decides §14.1. +> - **Totals:** a virtual meter is an *analysis view* by default and is never added on top of the meters it reads. +> The meter's `totals` override (`auto|always|never`) can make it replace its sources in its type's totals and in the +> bill (D-23). +> - **Legacy definitions (D-28):** at startup (`VirtualDefinitionUpgrade`), an expression-less virtual meter whose +> same-type incoming links name sources of one unit and kind gets the equivalent explicit sum stored. The run is +> idempotent and logged. Anything ambiguous is flagged "needs configuration" and is never guessed. The seed writes +> Summe Solar as `m4 + m5`, generation, kWh, cost rule `none`. +> - **Prices are not part of expressions** (`savings = self_consumption * unit_price` is not supported). A virtual +> meter's cost follows its cost rule (§7.5): `sourceCosts` (pure sums: the sources' own metered costs), `ownQuantity` +> (linear formulas: the evaluated quantity at its unit price) or `none`. PV savings are computed by the Solar page +> (self-consumption × the grid unit price, month by month) or through `ownQuantity`. +> - Export/import carries `meter_link` and remaps meter ids inside definitions (D-32). Deleting a meter names the +> virtual meters that read it and asks for confirmation (D-33). + ### 7.5 Cost `cost(bucket) = Σ(consumption_amount × active_unit_price) + base_price(prorated) − feed_in_credit − bonus`. Prices resolved by date from `tariff` (time-ranged). Currency from `app_setting`. Provide monthly-price and day-accurate-proration modes (§5.4). Categories roll costs up per `cost_category`; add `manual_cost` for meter-less categories (pool). +> **Deviation: the bill (D-22, D-34 – D-43, A-15 – A-19, A-21 – A-22, A-26 – A-27).** One cost engine (`CostReader` → +> `BillRun` → the Core `CostCalculator`) prices the portfolio, an energy type, a meter or a category for one resolved +> period. Every page, the REST API and the CSV export use it. Before the rework, costs summed every meter; this is +> what the engine does now: +> +> - **What is billed (D-22, D-34):** +> - Per energy type, the `grid_import` meters are billed if the type has one. Otherwise its *use* meters are billed: +> the `total_load` meter, or else the consumption roots of the topology. +> - Generation meters are never billed. +> - The feed-in credit is the FeedIn price × the export of `grid_export` meters. +> - Runtime meters and virtual views are not billed. +> - Submeters (topology children) are breakdowns, never added. So the seeded Strom bill is Zähler Netz × price, as +> the sheet's `Kosten` is. +> - **Separately billed subsections (D-35, A-19):** +> - A containment child with its own meter-scoped unit price is billed at that price, and its quantity is taken out +> of its billed ancestor. +> - The same applies to a consumer linked directly below a billed grid meter. +> - Quantity totals do not change. +> - **Prices (D-36):** +> - The spreadsheet's monthly convention is kept: the price valid on the **15th of each local month**. +> - Every bucket is cut into its local months and each part priced at its month's price, so week, month and year +> buckets, and the period total, agree. +> - There is no day-accurate proration (§14.3). +> - A reading interval longer than a month (a tank dipped every few months, quarterly burner hours) leaves its months +> unknown. A longer bucket over such months is priced as a whole when every month in it has the same price; +> otherwise it is unavailable, with an attention item (A-16). +> - **Tariff applicability (D-37):** +> - A UnitPrice or FeedIn tariff applies only when its unit's denominator matches the meter's normalized unit. +> Known scales are converted (ct, per 100 L, per MWh). +> - A parsed unit or currency mismatch makes the cost "unavailable (unit)", and the explanation names what does not +> fit (A-28). An unparseable unit applies, with a warning. +> - BasePrice units are per day, per month (the default) or per year. +> - The tariff editor checks units on save. It states that **Bonus, Discount and Tax are stored but not applied** +> (D-57). +> - **Not priced vs price gap vs zero (D-38, A-26, A-27):** +> - A billed scope with no UnitPrice tariff at any date is **not priced (no tariff)**. That is an attention item, not +> a partial total. +> - A hole in a priced scope's tariff history makes those months a **price gap** (cost unavailable). +> - An explicit zero tariff is a **valid zero**. The tariff editor refuses to save a new tariff without a value, so a +> deep link cannot create a free period by accident. +> - A missing FeedIn price is reported only where a `grid_export` meter exists. +> - Months with no grid meter in service, while use was measured, are unavailable rather than free (A-17). +> - A bucket with nothing booked reads "No data", never "Priced" (A-26). +> - The quantity analysis stays visible whenever the cost is unavailable. +> - **Standing charges (D-40, A-18):** +> - A standing charge accrues per local day, at value ÷ days in that local month, over the scope's service period +> (install or first data to retirement or now, regardless of reading gaps). +> - Type- and global-scoped charges are their own rows, **once per scope**, never copied onto each meter. +> - Meter-scoped fees stay on their meter. A fee on a meter that no bill line prices is its own row. +> - **Manual costs (D-41):** +> - A manual cost is booked in full, **once**, on its `PeriodStart` local day when that day is in the period and not +> after today. `PeriodEnd` is informational. +> - They count in the Overview, the Analysis page, categories and the export alike. +> - **Categories (D-42, A-22):** +> - A category's cost is the priced **non-overlapping cover** of its members, plus its manual costs. +> - The disjoint categories, *Uncategorized* and the standing-charge rows form the **composition**, which reconciles +> to the bill. +> - A category that overlaps another, or covers meters outside the bill, is an **overlapping view**. It is listed +> apart, never summed, and never drawn in the donut. The donut is drawn only when every slice is ≥ 0; otherwise +> signed bars are used. +> - A category whose members price nothing (calculated views, generation, runtime) says so rather than "no data". +> - **Virtual meters (D-39, A-15):** they are costed by their named rule: +> - `sourceCosts` (pure sums only): each physical source's own metered cost, once, with no scope-level standing +> charges. +> - `ownQuantity` (linear formulas without a constant): the evaluated quantity at its unit price. +> - `none`: every other formula, and generation sums (generation is never billed), which is why Summe Solar is not +> costed. +> - The rule is named next to every virtual cost. A virtual meter enters the bill only through the `always` +> override, and then replaces its sources. +> - The REST API reports a meter without a cost rule as `NotPriced` with the reason (A-21). +> - **Currency (D-43):** the configured `MeterVault__Currency` everywhere, through `Format.Money` / `InstanceCurrency`. +> A tariff in another currency is a unit mismatch; no conversion is made. +> - **Changes (D-07, A-23):** a cost change is stated only between complete figures, by one rule on every page +> (`OverviewComparison.Between`). If both totals are complete, it compares the totals. Otherwise it compares only the +> paired buckets that are complete on both sides, with the caption "over the part both periods cover". + --- ## 8. Dashboard & UX +> **Deviation (analysis rework, brief §3–§8, D-46 – D-55, D-58):** the panels below were rebuilt as one set of pages +> that share one period contract, one reader pair and one set of components. §8.0 describes what they share; the +> notes under §8.1 – §8.7 say what each page now is. + +### 8.0 Shared analysis contract (current) + +**Pages and navigation (D-47, D-48):** + +| Sidebar entry | Route | What it is | +|---|---|---| +| Overview | `/` | One selected period (default month to date): the cost with its composition, a card per energy type (quantities in their own units, cost with billing basis, change, freshness), the history chart, "What changed", attention items | +| Analysis | `/trends` (route kept) | Explore any scope: `portfolio`, energy `type`, cost `category`, one `meter`, or up to six `meters` side by side, by quantity or cost | +| Meters | `/meters` | All meters with period values, status, resolution, "data up to", how each counts, quick entry, filter by type | +| Energy types → *type* | `/energy/{id}` | Tabs `overview`, `history` (total or `view=meters`), `flow` (Sankey + table + Manage connections), `meters` | +| Specialized views | `/solar`, `/consumables` | Always listed; each shows a setup state when unsupported (no generation meter, no tank) | +| Data import | `/import`, `/import/wizard` | Import batches with revert; the CSV mapping wizard | +| Configuration | `/admin/*` | Energy type *definitions*, tariffs, cost categories, connectors, settings | + +- The meter page `/meters/{id}` is the per-meter hub. Its tabs are `analysis`, `readings`, `normalized`, `events`, + `tariffs`, `sources`. A virtual meter shows `analysis`, `events`, `tariffs`, `calculation`: no Readings or + Normalized data, and Calculation instead of Sources. +- Old links still work: `tab=consumption` opens `normalized`, and on a virtual meter `sources` opens `calculation` and + `readings` opens `analysis`. Tabs are resolved by key and meter mode, never by index. +- One-shot `action=` links (reading, swap, reset, delivery, tank level, note, edit, source) open their dialog once and + are then dropped from the address. +- Breadcrumbs (Overview → type → meter) carry the period. Expanded sidebar groups persist in a cookie (`mv-nav`), and + the group of the current page is always open. If the energy types cannot be loaded, the nav shows an error with + Retry instead of dropping the group. +- The app-bar "Find a meter" (a text button from the md breakpoint, an icon below) opens a meter's Analysis tab with + the current period, next to the quick-entry action. + +**Period and URL state (D-01 – D-08, D-46, A-13, `AnalysisQuery`):** + +| Key | Values | +|---|---| +| `scope`, `id`, `ids` | `portfolio`, `type`, `category`, `meter`, `meters` (`ids`, at most 6) | +| `metric` | `consumption`, `generation`, `export`, `runtime`, `net`, `cost`, `balance` | +| `period` | `mtd`, `last-month`, `ytd`, `prev-year`, `12m`, `24m`, `all`, `custom` (with `from`/`to`, local, inclusive) | +| `bucket` | `auto`, `day`, `week` (Monday), `month`, `year` | +| `compare` | `none`, `prev-period`, `prev-year`, `year:YYYY` | + +- The URL is the state: reload, share and Back reproduce the page. Defaults are never written: the Overview uses + `mtd`, history pages `12m` (12 calendar buckets ending with the current partial month), and every page compares with + the previous year (A-13). +- Toolbar and tab changes replace the history entry; drill-downs push. An invalid token falls back to the default + with a notice. +- `all` spans the scope's available data, never a fixed century. +- `auto` picks one bucket, at most 400 points per series. A finer explicit bucket is refused with a coarser + suggestion. +- A period resolves once per request against a captured "now" in the configured zone (§10). Quantities, costs, + comparisons and the export use the same half-open bounds. +- Comparisons shift in calendar units. The change figure is measured only over the range both periods cover, and both + exact ranges are shown (D-07). The percentage is "not applicable" for a zero or negative baseline; the absolute + difference is always shown (D-08). Colours depend on the metric: more generation is good, more consumption is not. +- Projections are separate, labelled with their method, and suppressed where coverage is insufficient (D-09). +- Pages load through a `LoadSequencer`, so a superseded load never overwrites a later one. Panels show a + loading/refresh/error-with-Retry state. +- Other page-specific keys: the energy page's `tab` and `view`, the Overview's `chart`, the record tabs' `from`/`to`. + +**Missing vs zero vs not priced (brief §4.3, D-14, D-38, A-24 – A-28):** +- Every figure carries its bucket status (§5.4) and provenance (measured, manual, imported, estimated, derived, + opening balance), worded next to it (`FigureText`). +- A true zero is a number and a bar on the baseline. +- An unknown bucket is a gap in the chart, marked "–", and "—" with its reason in the table: no data, only coarser + data, cannot be calculated, being prepared. +- Qualified values (partial, estimated) are marked "*". +- A period without data says "No data for this period", names the available dates and offers "Go to latest data". A + future range says it has not started yet. +- A chart never says "no data" when the reason is a coarser resolution (it names the resolution and offers the + interval that shows it) or a missing price (it names the cost status). +- Drill-downs only go where finer data exists. A monthly bucket never opens 31 unknown days. A physical meter's finest + bucket opens its Normalized data. A virtual meter's opens its own analysis over that bucket, whose source list links + on to the sources' records. +- Missing prices, stale sources, invalid calculations, rows dated after now and possible overlaps become attention + items, each with one targeted action (D-53). For example, "Add tariff" opens + `/admin/tariffs?scope=&id=&component=&from=&action=new` prefilled with the first uncovered month (D-52). + +**Shared components (`Components/Shared/Analysis`):** `PageHeader`, `AnalysisBreadcrumbs`, `PeriodToolbar`, +`AnalysisChart` (ApexCharts; one axis per unit; signed values around a real zero line; follows the light/dark theme +in the current circuit), `AnalysisTable` (the accessible equivalent of every chart), `MetricCard`, `ChangeChip`, +`ValueStatus`, `EmptyPeriodState`, `PendingState`, `PanelError`, `LoadPanel`, `ProjectionNote`, `ComparisonSummary`, +`AttentionList`, `SeriesContributions`. + +**CSV export (D-55):** `GET /export/analysis.csv` takes the same URL keys as the pages; every toolbar has "Export CSV". +It writes one row per bucket and series, with these columns: +- `series_id`, `series_name`, `kind`, `unit`; +- `bucket_start`, `bucket_end` (local ISO with offset; the end is exclusive, and a to-date bucket ends at now), + `timezone`; +- `value` (invariant, full precision; empty when unknown), `status`, `provenance`; +- `cost`, `cost_status`, `currency`, `comparison_value`. + +A bucket with nothing booked is `Missing`, never `Available`. Invalid requests (a notice, too many buckets, an unknown +scope) get a 400 with a plain-text reason. The endpoint is a UI endpoint, like the pages, and needs no API key. + ### 8.1 Overview - KPI cards: **Today**, **This month**, **This year** cost — each with Δ (absolute + %) vs the previous comparable period and an ↑/↓ indicator. - "Cost now" total across all categories. +> **Deviation (D-58, brief §7.1):** the Overview shows **one selected period** instead of Today/This month/This year +> cards. It has a period toolbar and defaults to month to date. The page shows: +> +> - the cost of the period, split into metered use, standing charges, manual costs and feed-in credit; +> - one card per energy type, with its measures in their own units (unlike quantities are never added), the cost with +> its billing basis, the change, freshness, and a link to that type with the same period; +> - the history chart with a metric selector (`chart=`), the previous-year overlay, a table toggle and drill-down; +> - "What changed", by category or by meter, with rows linking to the scoped Analysis page with the same dates; +> - the cost composition (§7.5): a donut only for non-negative disjoint slices, otherwise signed bars. Overlapping +> views are listed apart; +> - attention items, and "Latest month with data" with its month and basis (meters, manual costs or both, D-19). +> +> Changes are measured over the coverage both periods share (D-07). A period without data offers "Go to latest data"; +> the page never switches to history on its own. Missing categories or tariffs are small setup notes, never a +> prerequisite for seeing quantities. The REST summary keeps its legacy month/year windows (D-45). + ### 8.2 Cost breakdown / "what costs most" - Stacked bar or donut by `cost_category` for a selectable period; ranked list (most → least). - **Difference view** (explicitly requested): a table answering *"what cost more, what cost less this time"* — per category **and** per meter, **this month vs last month** and **this year vs last year**, columns `now | previous | Δ | Δ% | ↑/↓`, sorted by absolute impact. +> **Current:** both live on the Overview for the selected period and its comparison, not for fixed month/year windows. +> The composition is §7.5's. "What changed" lists categories or meters with current, previous, change and percentage +> where applicable, sorted by impact, plus a bill total row. A change is shown only between complete figures (A-23). + ### 8.3 Trends - Consumption and cost over time; **granularity toggle** day/week/month/year; per-meter or per-category; **previous-year overlay**. +> **Current:** this is the **Analysis** page (`/trends`, brief §7.4, nav "Analysis"). It offers: +> +> - Scope: all energy types, one energy type, a cost category, one meter, or a comparison of up to six meters. A +> seventh is refused with an explanation. +> - Metric: only what the scope supports. A category is always available by cost, and by quantity only when all its +> meters share one kind and unit; otherwise the page explains why and offers the alternatives. +> - The shared toolbar, with a calendar-year select (compare with any of the five years before), the chart (overlays +> up to three series; above that the comparison stays in the table), the table, drill-down and CSV export. +> - For per-type measures, total use and grid import side by side, never added. Portfolio cost is the same bill the +> Overview shows, with manual costs once. + ### 8.4 PV / Solar panel - Generation, self-consumption, grid feed/draw, **savings (Ersparnis)**, **autarky %**, **self-consumption %**. Time-filtered. +> **Current (D-54, `SolarService`):** one section per energy type that has generation. Nothing is inferred from names: +> meters are found by mode and by the effective roles `total_load`, `grid_import`, `grid_export` (A-07). +> +> - **Generation** is the type's generation measure, so a virtual sum such as Summe Solar is listed as a view and never +> added twice. +> - **Self-consumption** is total load − grid import, or else generation − grid export. +> - **Feed-in** is the grid export meter, or else generation − self-consumption (labelled as calculated; batteries are +> not modelled). +> - **Site use** is the total load meter, or else self-consumption + grid import. +> - **Savings** are self-consumption × the grid unit price, month by month through the cost calculator. +> - Autarky % and self-consumption % are shown when the roles allow. +> +> Every figure shows its status and how it was obtained. Units come from the meters; mixed units give "cannot be +> calculated". A missing role gets a setup card with the candidate meters, which lead into the meter editor (no raw +> role tags). The page has no CSV export, because the export has no derived measures. + ### 8.5 Oil / consumable panel - Tank level (cm + L), balance vs capacity gauge, deliveries log, burner runtime, effective **L/h** (fixed/empirical), **forecast to empty**, monthly cost. +> **Current (D-54, `ConsumableService`):** "Now" and "Selected period" are separate parts. +> +> - **Now:** the last dipstick with its date (and cm reading), an estimate that includes the deliveries since then, +> the fill bar (only when the level is known), and the forecast as a labelled projection (§7.3). +> - **Selected period:** use from the analysis reader, the deliveries of the period only, burner runtime of the type's +> runtime meters, and the burn rate (fixed, or empirical when runtime is in hours). +> - A period that ended shows the contents at its end. +> - The cost stays unknown, never 0 €, when the tank has no tariff or its months cannot be placed (A-16). + ### 8.6 Meter detail - Raw readings, normalized consumption, source status (last-seen, last value), tariff timeline, events (swaps/deliveries/corrections), measured-vs-estimated markers. +> **Current (brief §7.2, D-47, D-50):** the header carries identity, the energy type, mode and retirement chips, and +> the actions: primary entry by mode (Add reading / Record tank level), the "Record event" menu and Edit. The tab bar +> sits directly below the header. +> +> - **Analysis** tab: +> - The quantity card in the normalized unit (D-20), with any projection shown separately inside it. +> - The cost card, with its rule named or "Not costed" plus the reason, and the cost change (A-23). +> - The previous-year overlay, a full chart, the table, drill-down and CSV export. +> - Events and tariff changes inside the range, listed as context under the chart. +> - A "Data quality and coverage" section: resolution, data range, freshness, opening balance with "Set install +> date", and rows recorded after now. +> - For virtual meters, the source contributions. +> - **Readings**, **Normalized data** and **Events** tabs: +> - Server-side paging, 100 rows per page, keyset-ordered, filtered by the page's `period`/`from`/`to`. +> - Their toolbar shows the whole range listed, and rows dated after now carry an "After now" mark (A-29). +> - The Readings tab explains that raw readings are the audit record and that raw retention is not enforced. +> - **Tariffs** lists meter, type and global tariffs with their effective end and the one that applies now, plus "Add +> tariff for this meter". +> - **Sources** links each source's connector to its editor (the connector detour keeps the typed draft). +> - A virtual meter's **Calculation** tab shows the status, the formula with meter names beside each `m`, result +> kind and unit, cost rule, the meters read (also through nested calculations), and any problem with its dependency +> path. +> - The manual-entry dialog runs its own queries for the entered time, so its verdict never depends on a page of rows. + ### 8.7 Admin / config - CRUD for energy types, meters, sources, tariffs, cost categories, connectors; retention & locale/currency settings; import wizard; API keys. +> **Current (D-21, D-23, D-31, D-37, D-52, A-27, A-30):** +> +> - **Meter editor** (shared `MeterEditor`): +> - Roles use friendly names and one-line meanings, and are offered only for compatible modes. Saving a role moves it +> and names the meter that held it. +> - The totals override (Automatic / Always / Never) states its meaning and "In the totals now: …". A conflicting +> "Always" is refused, naming the other meter. +> - A virtual meter gets the calculation editor: Sum, Difference or Formula mode; sources picked by name, with unit, +> kind and dates; only valid cost rules offered, each with its reason. +> - A live preview uses the page's period (every preset, custom dates, all history) and shows per-source values and +> the incomplete months. +> - Saving a Sum can bring the incoming links in line with its sources, but links never change a calculation. +> - **Tariffs:** the deep link opens a prefilled dialog once, scoped to what can price that meter or type. Units are +> checked live, a new tariff needs a value, and Bonus/Discount/Tax are marked "not applied". +> - **Energy types** under Configuration edit the definitions ("Energy type definitions"); analysis lives under the +> Energy types nav group. +> - **Settings** is read-only. It shows the zone, the currency, raw retention ("Not enforced"), the normalization +> revision and zone, how many meters have current analysis data or are waiting for a rebuild, and calculated meters +> by status. +> - Flow topology is edited from the energy type's Flow tab ("Manage connections"): cycle-, type- and +> duplicate-checked, and it never touches a stored formula. Physical meters can also set their upstream meters in +> the editor. + > **Legacy monthly history:** imported data is monthly-granular. Offer per-import choice: keep native monthly buckets, or **linearly interpolate to daily** (energietracker-style) so old and new data render on the same axes. Interpolated points are marked `quality = interpolated`. +> +> **Current behaviour (D-14, D-57):** monthly data stays monthly and is never interpolated. A day or week bucket over +> it reads "only coarser data", the chart names the data's resolution and offers the interval that shows it, and a +> drill-down never opens days a monthly import cannot resolve (D-51). --- @@ -496,17 +933,54 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se | `GET /api/v1/sources/status` | Connector/source health. | | `GET /healthz` | Liveness/readiness (for Gatus). | +> **Current contract (D-45, A-16, A-21; pinned by `ApiContractTests`):** `/consumption`, `/cost` and +> `/dashboard/summary` keep every existing field, name and type. Their numbers now come from the analysis reader and +> the cost engine, so they match the pages. What changed is only added as new fields: +> +> | Endpoint | Behaviour | Added fields | +> |---|---|---| +> | `GET /api/v1/consumption?meter=&from=&to=` | Monthly rows as before. Instants with any offset are accepted (converted to UTC; an offset used to be a 500). Actuals stop at now. A month without data is absent, not a 0. A virtual meter is evaluated from its formula. | `status` (BucketStatus), `issue` (why a value is not plain), `kind`, `unit` (normalized) | +> | `GET /api/v1/cost?meter=&from=&to=` | `cost` stays numeric: 0 when nothing could be priced, with the reason beside it. The meter is priced by its rule (the bill line, a subsection at its unit price, a virtual meter by its cost rule). Generation and runtime meters, and meters that cannot be evaluated, are not costed. | `costStatus` (`Priced`, `Partial`, `NotPriced`, `PriceGap`, `UnitMismatch`), `costAvailability` (the status of the quantities behind the cost, e.g. `Unresolved`, `Invalid`), `costRule`, `notCosted` (the reason), `missingPrices[]` (component, reason, scope, first/last month, tariff, unit issue, credit), `status`, `issue`, `kind`, `unit` | +> | `GET /api/v1/dashboard/summary` | Keeps its legacy windows (calendar month and year to now against the whole previous ones) but prices the bill (§7.5). | `deltaPercentApplicable` per KPI (the percentage is 0 and not applicable for a zero or negative baseline), `latestMonth` `{period, basis}` | +> +> `/consumption` and `/cost` take one `meter` and answer by calendar month, as before the rework; the table's `scope` +> and `bucket` parameters are not implemented. The UI's analysis CSV (`GET /export/analysis.csv`, §8.0) is not part of +> `/api/v1` and needs no API key. + --- ## 10. Non-functional - **Time & DST:** store UTC; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`. + *Deviation (D-01 – D-06, D-58):* + - **Zone:** the **configured** zone (`MeterVault__TimeZone`, normalized to its IANA id) is used everywhere: + normalization, rollups, readers, periods, the export. Berlin is never hard-coded, and a zone change rebuilds the + rollups. + - **Bounds:** a period resolves into a local inclusive date range for display and a **half-open** UTC range + `[from, to)` for queries. `to` is the local midnight after the end date, or the captured "now" for to-date + periods. + - **Calendar:** days start at local midnight, and a DST day really has 23 or 25 hours. Weeks start on Monday. Months + and years are local. + - **Clock:** "now" is read once per request from the registered `TimeProvider` (`InstanceClock` for pages). + Services never read the clock; tests freeze it. + - **After now:** rows whose interval ends after now are never counted as actuals; they are reported as "recorded + after now" (D-04). - **Auth:** optional built-in local accounts; **reverse-proxy trust** mode honouring `X-Forwarded-User`/`Remote-User` behind Authelia/Traefik; API keys for machine access. Default: single admin user + one ingest API key. - **Observability:** `/healthz`, structured logs (Serilog), optional Prometheus `/metrics`. - **Config:** environment variables + a settings UI; secrets via env/Docker secrets or encrypted at rest (never in DB plaintext) — see §6.4. - **i18n:** `en` (default for OSS) + `de`; locale-aware number/currency/date. Ship a German locale that matches the source data conventions. - **Backup:** document `pg_dump`/Timescale backup; provide a full **JSON export/import** for portability. - **Performance:** dashboards read aggregates only; raw reads paginated and time-bounded. + *Current:* dashboards read the rollup tables (§5.4). A request sends a constant handful of statements, whatever the + meter count. The point limit (400 per series) and the series limit (6 on a chart) are enforced before any SQL runs. + The brief's target is a 10-year monthly request for 100 meters in under 2 s; it was measured against a synthetic + 1,000-meter × 10-year dataset (§13, `docs/ANALYSIS_REPORT.md`). Raw record tabs are paged (100 rows, keyset) and + date-filtered. +- **Accessibility & layout (brief §8):** every chart has a table equivalent; colour is never the only cue (words, + arrows, signed values, dashed overlays); visible focus rings; keyboard-reachable drill links; no page-wide overflow + at 360 px; charts follow the light/dark theme (cookie `mv-theme`) within the circuit. MudBlazor's own labels are + localized (`MeterVaultMudLocalizer`). +- **Currency:** `MeterVault__Currency` (default `EUR`) for every amount (D-43). --- @@ -528,6 +1002,18 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se /docs architecture, setup, HA/Tasmota wiring, API, screenshots ``` +> *Current layout:* the analysis rework added these folders. +> - `src/Core/Analysis`: pure rules for time and periods, coverage, rollups, quantities and units, totals and category +> cover, virtual formulas, and the cost calculator. +> - `src/Infrastructure/Analysis`: the reader, the catalog and the virtual upgrade. +> - `src/Infrastructure/Costing`: `CostReader` and `BillRun`. +> - `src/App/Analysis`: the URL contract and the chart, table and attention models. +> - `src/App/Components/Shared/Analysis`: the shared components. +> - Page folders under `src/App/Components/Pages`. +> +> The fixtures live in `sampledata/`. CI is Gitea Actions (`.gitea/workflows/`), publishing to the Gitea container +> registry. `CLAUDE.md` holds the maintained layout. + - **CI (GitHub Actions):** build → test (spin Timescale) → publish Docker image to **GHCR** (amd64; add arm64 if desired) on tag. - **License:** pick before release — **MIT** (max adoption; matches energietracker/your prior assets) or **AGPL-3.0** (keeps hosted forks open). Default suggestion: **MIT**, unless keeping SaaS forks open-source matters to you. - **Docs:** a "wire up HA/Tasmota" guide is the highest-leverage doc for adoption. @@ -545,6 +1031,16 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se - **M6 — API & auth.** REST + OpenAPI; API keys; reverse-proxy trust. *Exit:* HA can push via `POST /readings`; Swagger published. - **M7 — Release polish.** i18n (de/en); retention settings; JSON export/import; Unraid template; CI → GHCR; README + wiring guide. *Exit:* `docker compose up` from a clean host yields a working, documented instance. +> *After M7:* the dashboard/analysis rework was built in five phases: +> 1. shared semantics and fixtures; +> 2. virtual evaluation and migration; +> 3. history and navigation; +> 4. Overview and specialized pages; +> 5. integration, performance and documentation. +> +> It replaced M4's continuous aggregates and "monthly + prorated" cost view with rollup tables and the month-by-month +> bill (§5.4, §7.5), and M5's panels with the pages of §8.0. M7's "retention settings" remain display-only (D-57). + --- ## 13. Testing strategy @@ -554,21 +1050,89 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se - **Golden fixtures:** the four CSVs with expected monthly consumption/cost tables. A regression test asserts computed ≈ spreadsheet (define tolerance for rounding; the sheet rounds to cents / whole kWh). - **Load smoke (optional):** synthetic 1000-meter × N-year generator to validate aggregate query latency and compression ratio. +> **Current (analysis rework, D-56):** `Core.Tests` has 1,733 tests and `Integration.Tests` 746, all passing at the end +> of the rework. The continuous-aggregate refresh test was replaced by +> `CostReconciliationTests.Monthly_rollup_equals_the_consumption_it_sums` (water Dec 2022 = 14 m³). `SchemaTests` +> pins that the aggregates and their jobs are gone and that the analysis tables cascade with their meter. +> +> - **Frozen clock, pure (Core):** +> - `PeriodResolverTests`, `ComparisonResolverTests`, `BucketPlannerTests`: local New Year, Berlin DST in spring +> and autumn (23 h and 25 h days, the repeated hour), 29 Feb, 31 March against February, shorter months, New York +> (a zone behind UTC). +> - Coverage, rollups, provenance and freshness: `Coverage*Tests`, `RollupBuilderTests`, `MatchedCoverageTests`, +> `ProvenanceRulesTests`, `FreshnessRulesTests`. +> - Totals and categories: `TotalsPolicyTests` (the seeded classification, D-22), `CategoryCoverTests`, +> `SeparatelyBilledSubmeterTests`. +> - Virtual formulas: `FormulaParserTests`, `VirtualValidatorTests`, `VirtualEvaluatorTests`, `DependencyGraphTests`, +> `LegacyVirtualDerivationTests`: A+B, A−B, missing vs zero, nested, loop, division by zero. +> - Costing: `CostCalculator*Tests`, `CostingTariffBookTests`, `TariffUnitTests`. +> - Units and changes: `UnitsTests`, `ChangeTests`. +> - **Reader and costs (Testcontainers):** +> - `AnalysisReaderTests`: the worked examples, local days across DST and in New York, rows after now, new readings +> and corrections visible on the next read. +> - `AnalysisDataTests`: rollups written by diff, rows removed behind the tracker's back. +> - `CostReaderTests`: missing vs zero tariff, price gaps, bucket-independent totals, standing charges once per +> scope, manual costs once, virtual cost rules. `CostReviewFixTests` adds a category whose members price nothing. +> - `SeededBillTests`: the D-44 goldens. The yearly bill equals the sheet's `Jahreskosten` within ±0.02 € for 2022, +> 2025 and 2026; 2023 and 2024 are pinned to the documented 3.78 € / 0.46 € differences. It also pins the +> composition and a manual-cost-only instance. +> - `CostConsistencyTests`: the same cost change on four pages, and matching standing charges. +> - `CoverageOfFixturesTests`, and the unchanged reconciliation suites (electricity, water, oil, costs; Netz +> Einsparung through the new evaluator, D-29). +> - **Contracts and export:** `ApiContractTests` (fields and types of `/consumption`, `/cost`, `/dashboard/summary`, +> offsets, not-costed meters), `AnalysisExportEndpointTests`, `AnalysisCsvWriterTests`, and `ExportRoundTripTests` +> (links and virtual definitions remapped, a deleted meter's tariffs not restored). +> - **Pages without a browser:** +> - Loaders against a database: `MeterAnalysisLoaderTests`, `AnalysisPageLoaderTests`, `EnergyTypePageTests`, +> `OverviewDataTests`, `SolarServiceTests`, `ConsumableServiceTests`, `MeterDraftPreviewTests`, +> `VirtualManagementTests`, `MeterDetailServiceTests` (keyset paging, half-open filters). +> - Pure UI models: `AnalysisQueryTests`, `AnalysisNavigationTests`, `AnalysisChartModelTests`, +> `AnalysisTableModelTests`, `AttentionItemsTests`, `LoadSequencerTests`, `MeterPageLogicTests`, +> `EnergyPageTests`, `OverviewLogicTests`, `MeterEditorLogicTests`, `TariffEditingTests`. +> - Server-rendered HTML in EN and DE: `DashboardRenderTests`, `OverviewPageTests`, `AnalysisComponentRenderTests`, +> `AdminPagesRenderTests`, `MeterSourcesRenderTests`. +> - `StringResourceTests`, `EnumDisplayNameTests` and `FormatCultureTests` pin both languages and the currency. +> - **Browser checks:** interactive ApexCharts, browser history, theme switching and responsive layout were checked +> against the seeded instance. CDP scripts drove Chrome in EN/DE, light/dark, 1440/390/360 px. That acceptance walk +> is recorded in `docs/ANALYSIS_REPORT.md`. No bUnit or Playwright suite is in the repository. +> - **Performance** (`tests/Integration.Tests/Performance`, trait `Category=Performance`): skipped unless +> `METERVAULT_PERF=1`. `SyntheticLoadTests` loads a deterministic 1,000-meter × 10-year dataset (≈1.34 M readings; +> monthly, daily and hourly meters, tanks, roles, links, 20 virtual meters nested up to three levels) through the +> real pipeline. `ReaderTimingTests` times the reader and cost scenarios (the brief's target: 100 meters, 10 years +> monthly, under 2 s), counts SQL statements, records query plans, the startup rebuild and per-meter recompute +> cost. Results are in `docs/ANALYSIS_REPORT.md`. + --- ## 14. Open questions & defaults Pick the **default** and flag it if unsure; only ask when a question isn't listed here. -1. **Virtual meters: compute-on-write or compute-on-read?** *Default:* compute-on-read for dashboards, materialize to `consumption` only if a virtual meter is referenced by cost. (Avoids recompute storms; revisit if slow.) -2. **Legacy monthly import → daily interpolation or native monthly?** *Default:* offer both per import; interpolation off by default, points marked `interpolated`. -3. **Cost proration when price changes mid-month.** *Default:* day-accurate proration available, but the **displayed** monthly figure uses the month's dominant price to match the spreadsheet unless the user opts into proration. -4. **Instant-rate (power/flow) integration in v1?** *Default:* schema-supported, worker deferred to post-v1 (Tasmota already gives cumulative `ENERGY.Total`, so it's rarely needed). +1. **Virtual meters: compute-on-write or compute-on-read?** *Default:* compute-on-read for dashboards, materialize to `consumption` only if a virtual meter is referenced by cost. (Avoids recompute storms; revisit if slow.) *Decided (D-58):* computed on read everywhere, costs included; nothing is materialized. +2. **Legacy monthly import → daily interpolation or native monthly?** *Default:* offer both per import; interpolation off by default, points marked `interpolated`. *Current (D-57):* native monthly only; interpolation is not offered. +3. **Cost proration when price changes mid-month.** *Default:* day-accurate proration available, but the **displayed** monthly figure uses the month's dominant price to match the spreadsheet unless the user opts into proration. *Decided (D-36, A-16):* one price per local month, the one valid on the 15th (the sheet's convention); no proration mode. Every bucket is priced month by month, so bucket size never changes a total. A reading interval longer than a month is priced as a whole only when its months share one price; otherwise it is unavailable, with an attention item. Standing charges accrue per day (D-40). +4. **Instant-rate (power/flow) integration in v1?** *Default:* schema-supported, worker deferred to post-v1 (Tasmota already gives cumulative `ENERGY.Total`, so it's rarely needed). *Current:* the `instant_rate` normalizer integrates the rate over time (trapezoidal), and a gap longer than max(1 h, 10 × the median sample interval) opens a coverage gap (D-13). There is no rate-specific ingestion worker: rate values arrive through the ordinary sources like any other reading. 5. **Multi-user?** *Default:* single admin + reverse-proxy trust; full accounts post-v1. 6. **.NET version pin.** *Default:* current LTS at implementation time; keep `TargetFramework` in one place. 7. **License.** *Default:* MIT unless you want AGPL's copyleft on hosted forks. 8. **Name.** `MeterVault` is a placeholder — decide before first public tag. +The analysis rework settled these further questions. Each is recorded in `ANALYSIS_IMPLEMENTATION_NOTE.md`. + +9. **Raw retention (§5.5).** *Decided (D-57):* not enforced until recompute can start from stored consumption. The + setting is shown as "Not enforced". +10. **What a type's total and bill count.** *Decided (D-22, D-34):* the non-overlapping topology roots, with + consumption and generation apart. The bill counts grid import where there is one, otherwise use. Submeters and + virtual views are never added; the `always` override lets a virtual meter replace its sources (D-23). +11. **Virtual costs.** *Decided (D-39, A-15):* a named cost rule. `sourceCosts` for pure sums, `ownQuantity` for + linear formulas, `none` otherwise and for generation sums. The default for new virtual meters is "analysis only" + in the totals. +12. **Default comparison.** *Decided (A-13):* the previous year, at the same elapsed point, measured over what both + periods cover (D-07). +13. **Default periods.** *Decided (D-02):* the Overview uses month to date. History pages use the last 12 months: 12 + calendar buckets, the current one partial. +14. **Missing tariff.** *Decided (D-38):* "not priced", never 0. An explicit zero tariff is a valid zero. + --- ## Appendix A — CSV import dialect (from the reference files) @@ -589,7 +1153,7 @@ Pick the **default** and flag it if unsure; only ask when a question isn't liste |------------|---------------| | Zähler (Haus/Netz/Auto/Solar) | `meter` (`cumulative_`/`generation_counter`) | | Verbrauch / Erzeugung | `consumption.amount` (kind 0/1) | -| Solar Erzeugung / Eigenverbrauch / Ersparnis / Netz Einsparung | `virtual` meters via expressions | +| Solar Erzeugung / Eigenverbrauch / Ersparnis / Netz Einsparung | `virtual` meters via expressions (quantities only: Solar Erzeugung = `m(Solar 1) + m(Solar 2)`, Netz Einsparung = `m(Haus) − m(Netz)`). Ersparnis is a price × quantity, which expressions do not support (§7.4). It is the Solar page's savings, or a virtual meter's `ownQuantity` cost. | | €/kWh, €/m³, €/100l | `tariff.unit_price` (time-ranged) | | Grundpreis / Abschlag | `tariff.base_price` | | Betriebststunden | `runtime_counter` meter | diff --git a/src/App/Analysis/AnalysisChartModel.cs b/src/App/Analysis/AnalysisChartModel.cs new file mode 100644 index 0000000..63c8b43 --- /dev/null +++ b/src/App/Analysis/AnalysisChartModel.cs @@ -0,0 +1,570 @@ +using System.Globalization; +using MeterVault.App.Localization; +using MeterVault.App.Theme; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Infrastructure.Analysis; +using MudBlazor; +using MudBlazor.Utilities; + +namespace MeterVault.App.Analysis; + +/// How a chart series is drawn. +public enum ChartSeriesStyle +{ + Bar, + Line, +} + +/// Why a chart value has no number (brief §4.3): the chart words an empty plot by it, never as "no data" alone. +public enum ChartGap +{ + /// The value is known. + None, + + /// No data, a calculation that cannot be evaluated, data being prepared. + NoData, + + /// The data exists only at a coarser resolution than the bucket (D-14 unresolved). + Unresolved, + + /// A cost whose quantities are known but whose price is not (no tariff, a tariff gap, a unit mismatch, D-38). + NotPriced, +} + +/// +/// One value as the chart draws it: the number (null for an unknown bucket, which is a gap — never a zero), whether it is +/// qualified (partial, estimated, not fully priced), and the words the tooltip adds to it. +/// +public sealed record ChartValue(double? Value, bool IsQualified, string? Note) +{ + /// Why there is no number; when there is one. + public ChartGap Gap { get; init; } + + /// The status in words ("Only coarser data", "Not priced (no tariff)"). + public string? Status { get; init; } + + /// A quantity bucket (). + public static ChartValue Of(BucketValue value, Func? meterName = null) + { + ArgumentNullException.ThrowIfNull(value); + + var status = FigureText.Of(value, meterName); + var gap = status.IsKnown ? ChartGap.None : value.Status == BucketStatus.Unresolved ? ChartGap.Unresolved : ChartGap.NoData; + return new ChartValue(status.IsKnown ? value.Value : null, status.IsQualified, status.IsQualified ? status.Full : null) + { + Gap = gap, + Status = status.Status, + }; + } + + /// A cost figure (). + public static ChartValue Of(CostAmount amount) + { + ArgumentNullException.ThrowIfNull(amount); + + var status = FigureText.Of(amount); + var gap = status.IsKnown ? ChartGap.None + : amount.Status is CostStatus.NotPriced or CostStatus.PriceGap or CostStatus.UnitMismatch ? ChartGap.NotPriced + : amount.Availability == BucketStatus.Unresolved ? ChartGap.Unresolved + : ChartGap.NoData; + return new ChartValue(status.IsKnown ? amount.Cost : null, status.IsQualified, status.IsQualified ? status.Full : null) + { + Gap = gap, + Status = status.Status, + }; + } +} + +/// Why a chart has nothing to draw (). +public enum ChartEmptyReason +{ + /// Something can be drawn. + None, + + /// No value is known: no data, or nothing that can be evaluated. + NoData, + + /// The data is only resolved coarser than the buckets (a monthly import in days): a coarser interval shows it. + Unresolved, + + /// The quantities are known but not priced: the cost is unavailable until a tariff covers it. + NotPriced, +} + +/// +/// A series the analysis chart draws (D-49, brief §8): a stable key, a display name (user data is never translated), the +/// unit or currency its values are in, one value per bucket of the plan, bar or line, and whether it is the comparison +/// overlay of another series. +/// +/// +/// A comparison overlay's values are paired with the current buckets by index (, A-10): value i +/// belongs to the image of bucket i. It shares the colour of and is drawn dashed (a line) or +/// faded (bars), so the pairing is readable without colour. +/// +public sealed record AnalysisChartSeries +{ + /// A quantity series. + /// A stable key (, or any invariant token). + /// The name shown in the legend and tooltip. + /// The normalized unit of every value (D-20). + /// One value per bucket. + public AnalysisChartSeries(string key, string name, string? unit, IReadOnlyList values) + : this(key, name, unit, values, null) + { + } + + /// A quantity series whose derived values name the meter they miss (). + /// A stable key. + /// The name shown in the legend and tooltip. + /// The normalized unit of every value (D-20). + /// One value per bucket. + /// Names a meter id a value's dependency path ends at; "#id" without it. + public AnalysisChartSeries(string key, string name, string? unit, IReadOnlyList values, Func? meterName) + : this(key, name, unit, null, [.. (values ?? throw new ArgumentNullException(nameof(values))).Select(v => ChartValue.Of(v, meterName))]) + { + } + + private AnalysisChartSeries(string key, string name, string? unit, string? currency, IReadOnlyList values) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(name); + + Key = key; + Name = name; + Unit = unit; + Currency = currency; + Values = values; + } + + public string Key { get; init; } + + public string Name { get; init; } + + /// The quantity unit; null for money. + public string? Unit { get; init; } + + /// The ISO currency code when the values are money (D-43). + public string? Currency { get; init; } + + public IReadOnlyList Values { get; init; } + + public ChartSeriesStyle Style { get; init; } = ChartSeriesStyle.Bar; + + /// True for the comparison overlay of another series. + public bool IsComparison { get; init; } + + /// For an overlay, the key of the series it compares; it takes that series' colour. + public string? BaseKey { get; init; } + + /// True when the values are money. + public bool IsMoney => Currency is not null; + + /// What the axis of this series is labelled with: the currency symbol for money, else the unit. + public string AxisUnit => Currency is { } currency ? Format.CurrencySymbol(currency) : Unit?.Trim() ?? string.Empty; + + /// A cost series: one figure per bucket, in . + public static AnalysisChartSeries ForCost(string key, string name, string currency, IReadOnlyList amounts) + { + ArgumentNullException.ThrowIfNull(amounts); + ArgumentException.ThrowIfNullOrWhiteSpace(currency); + + return new AnalysisChartSeries(key, name, null, currency, [.. amounts.Select(ChartValue.Of)]); + } + + /// + /// A reader series (a meter or a measure total). defaults to the meter's name, or for a + /// measure to the measure's wording. + /// + /// The series. + /// The legend name; the meter's name or the measure's wording by default. + /// Bars by default. + /// Names the meter a derived value misses (a source without data, a loop); "#id" without it. + public static AnalysisChartSeries ForSeries( + AnalysisSeries series, string? name = null, ChartSeriesStyle style = ChartSeriesStyle.Bar, Func? meterName = null) + { + ArgumentNullException.ThrowIfNull(series); + + return new AnalysisChartSeries(series.Key.Id, name ?? NameOf(series), series.Unit, series.Values, meterName) { Style = style }; + } + + /// + /// The comparison overlay of a reader series (), paired with its buckets by + /// index; null when no comparison was read. + /// + /// The series. + /// The overlay's name, e.g. . + /// Line by default: a dashed line over the bars. + /// Names the meter a derived value misses; "#id" without it. + public static AnalysisChartSeries? ComparisonOf( + AnalysisSeries series, string name, ChartSeriesStyle style = ChartSeriesStyle.Line, Func? meterName = null) + { + ArgumentNullException.ThrowIfNull(series); + + return series.Comparison is { } comparison + ? new AnalysisChartSeries(series.Key.Id + ":cmp", name, series.Unit, comparison.Values, meterName) + { + Style = style, + IsComparison = true, + BaseKey = series.Key.Id, + } + : null; + } + + /// The comparison overlay of a cost series, priced in the paired buckets (). + public static AnalysisChartSeries ComparisonForCost( + string baseKey, string name, string currency, IReadOnlyList amounts, ChartSeriesStyle style = ChartSeriesStyle.Line) => + ForCost(baseKey + ":cmp", name, currency, amounts) with { Style = style, IsComparison = true, BaseKey = baseKey }; + + /// "Haus (same period last year)": a series name with the comparison it shows. + public static string ComparisonName(string name, ComparisonRequest comparison) + { + ArgumentNullException.ThrowIfNull(comparison); + + return name + " (" + comparison.Display() + ")"; + } + + /// The name of a reader series: the meter's name, or the measure's wording for a total. + public static string NameOf(AnalysisSeries series) + { + ArgumentNullException.ThrowIfNull(series); + + return series.Name.Length > 0 || series.Key.Measure is not { } measure ? series.Name : measure.Display(); + } +} + +/// +/// The chart colours of one theme mode, taken from the MudBlazor palette (D-49): the series hues in a fixed order, a +/// muted hue for overlays without a base, and the text, grid and zero-line colours. Colour follows the series, in the +/// order the series are given, so a meter keeps its hue when others are added after it. +/// +/// +/// The order — primary, secondary, info, then error, warning and success for a fourth to sixth meter — is the palette +/// order whose neighbours stay distinguishable under protan and deutan vision (checked with an OKLab ΔE validator: ≥ 9.5 +/// in dark mode, ≥ 6.3 in light mode, where the legend and the table are the secondary encoding). +/// +public sealed record ChartPalette(bool IsDark, IReadOnlyList Series, string Muted, string Text, string Grid, string Baseline, string Surface) +{ + /// The colours of the light or dark palette of . + public static ChartPalette For(bool isDark) + { + Palette palette = isDark ? MeterVaultTheme.Instance.PaletteDark : MeterVaultTheme.Instance.PaletteLight; + return new ChartPalette( + isDark, + [Hex(palette.Primary), Hex(palette.Secondary), Hex(palette.Info), Hex(palette.Error), Hex(palette.Warning), Hex(palette.Success)], + palette.GrayDefault, + Rgba(palette.TextSecondary), + Rgba(palette.LinesDefault), + Rgba(palette.TextSecondary), + Hex(palette.Surface)); + } + + /// The hue of the -th series (0-based). + public string SeriesColor(int index) => Series[((index % Series.Count) + Series.Count) % Series.Count]; + + /// #RRGGBB: the chart library does its own colour arithmetic and expects plain hex. + public static string Hex(MudColor color) + { + ArgumentNullException.ThrowIfNull(color); + + return string.Create(CultureInfo.InvariantCulture, $"#{color.R:X2}{color.G:X2}{color.B:X2}"); + } + + /// rgba(r,g,b,a) with the colour's own alpha. + public static string Rgba(MudColor color) + { + ArgumentNullException.ThrowIfNull(color); + + return string.Create(CultureInfo.InvariantCulture, $"rgba({color.R},{color.G},{color.B},{Math.Round(color.APercentage, 3)})"); + } + + /// A #RRGGBB colour at as rgba(…); anything else is returned unchanged. + public static string WithAlpha(string hex, double alpha) + { + ArgumentNullException.ThrowIfNull(hex); + + if (hex.Length != 7 || hex[0] != '#' + || !int.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var rgb)) + { + return hex; + } + + var a = Math.Clamp(alpha, 0, 1); + return string.Create(CultureInfo.InvariantCulture, $"rgba({(rgb >> 16) & 0xFF},{(rgb >> 8) & 0xFF},{rgb & 0xFF},{Math.Round(a, 3)})"); + } +} + +/// +/// One point of a chart series, as the chart library receives it: the bucket's index and label, the value (null for a +/// gap), its fill (faded when qualified) and the tooltip text, formatted here with the reader's culture, unit and +/// currency and the status in words. +/// +public sealed record ChartPoint(int Index, string Label, decimal? Value, string? FillColor, string Tooltip, bool IsQualified); + +/// One drawn series of a . +public sealed record ChartPanelSeries( + string Key, + string Name, + ChartSeriesStyle Style, + bool IsComparison, + string Color, + int StrokeWidth, + int DashSpace, + IReadOnlyList Points); + +/// +/// One chart with one y-axis: the series of one unit or currency. Series of different units are never drawn against +/// two scales on one plot; they get a panel each. +/// +/// The axis unit ("kWh", "€"); empty when the values have none. +/// A value is below zero: the zero line is drawn. +/// A value is above zero. +/// +/// A current (not comparison) point has a value that is qualified (partial, estimated, not fully priced): the note under +/// the chart explains . +/// +/// At least one point has a value. +/// +/// The panel's axis labels: the bucket labels, with where one of the panel's own +/// current series has a qualified value and where one has none — a gap in the +/// cost panel does not mark the quantity panel's months. +/// +/// A current point has no value: the note under the chart explains . +public sealed record ChartPanel( + string Unit, + IReadOnlyList Series, + bool HasNegative, + bool HasPositive, + bool HasMarked, + bool HasValues, + IReadOnlyList Labels, + bool HasGaps = false); + +/// +/// What the analysis chart draws (D-49): the bucket labels, which buckets are marked as qualified, and the panels — +/// computed without the chart library, so the rules are testable: unknown values stay gaps, qualified buckets are marked +/// in the label (not by colour alone), overlays pair by index, labels carry the year across years. +/// +/// One label per bucket, unmarked (each panel marks its own, ). +/// Per bucket: some current series is qualified there. +/// One per unit, in the order the units first appear. +public sealed record AnalysisChartPlan(IReadOnlyList Labels, IReadOnlyList Marked, IReadOnlyList Panels) +{ + /// The mark added to the label of a bucket with a qualified value; the note under the chart explains it. + public const string Marker = " *"; + + /// + /// The mark added to the label of a bucket without a value (no data, not priced, only coarser data): it is a gap, not + /// a zero — a true zero is drawn on the baseline — and the note under the chart says so. + /// + public const string GapMarker = " –"; + + /// The bar outline: a true zero is drawn as this line on the baseline, a gap draws nothing. + public const int BarStrokeWidth = 2; + + /// Why nothing can be drawn; when something can. + public ChartEmptyReason EmptyReason { get; init; } + + /// For : the cost's status in words ("Not priced (no tariff)"). + public string? EmptyStatus { get; init; } + + /// The fill alpha of a qualified bar. + public const double QualifiedAlpha = 0.45; + + /// The fill alpha of a comparison bar. + public const double ComparisonAlpha = 0.4; + + /// True when anything can be drawn. + public bool HasValues => Panels.Any(p => p.HasValues); + + /// Plans the chart. + /// The buckets of the plan (), oldest first. + /// The series; values beyond the buckets are ignored, missing ones are gaps. + /// The theme's colours. + /// The comparison buckets paired with (A-10), to name an overlay's own bucket in its tooltip. + public static AnalysisChartPlan Build( + IReadOnlyList buckets, + IReadOnlyList series, + ChartPalette palette, + IReadOnlyList? pairs = null) + { + ArgumentNullException.ThrowIfNull(buckets); + ArgumentNullException.ThrowIfNull(series); + ArgumentNullException.ThrowIfNull(palette); + + var labels = BucketLabels(buckets); + var marked = new bool[buckets.Count]; + foreach (var current in series.Where(s => !s.IsComparison)) + { + for (var i = 0; i < buckets.Count; i++) + { + marked[i] |= ValueAt(current, i).IsQualified; + } + } + + // Colour follows the series in the order given, never its rank; an overlay takes its base's colour. + var colours = new Dictionary(StringComparer.Ordinal); + var next = 0; + foreach (var current in series.Where(s => !s.IsComparison)) + { + if (!colours.ContainsKey(current.Key)) + { + colours[current.Key] = palette.SeriesColor(next++); + } + } + + var names = UniqueNames(series); + var panels = new List(); + foreach (var group in series.Select((s, i) => (Series: s, Name: names[i])).GroupBy(x => x.Series.AxisUnit, StringComparer.Ordinal)) + { + // A value that is known but qualified gets "*", a bucket without a value "–": the reader tells a partial month + // from an empty one without colour, and a true zero carries no mark at all. + var shown = labels + .Select((label, i) => + { + var current = group.Where(x => !x.Series.IsComparison).Select(x => ValueAt(x.Series, i)).ToList(); + var mark = (current.Any(v => v.Value is not null && v.IsQualified) ? Marker : string.Empty) + + (current.Any(v => v.Value is null) ? GapMarker : string.Empty); + return label + mark; + }) + .ToList(); + var drawn = new List(); + bool negative = false, positive = false, markedHere = false, gapsHere = false, values = false; + foreach (var (item, name) in group) + { + var colour = item.IsComparison + ? item.BaseKey is { } baseKey && colours.TryGetValue(baseKey, out var baseColour) ? baseColour : palette.Muted + : colours[item.Key]; + + var points = new List(buckets.Count); + for (var i = 0; i < buckets.Count; i++) + { + var value = ValueAt(item, i); + var number = ToDecimal(value.Value); + negative |= number < 0; + positive |= number > 0; + values |= number is not null; + markedHere |= !item.IsComparison && value.IsQualified && number is not null; + gapsHere |= !item.IsComparison && number is null; + + var pairLabel = item.IsComparison && pairs is not null && i < pairs.Count + ? Format.BucketLabel(pairs[i].Comparison, includeYear: true) + : null; + points.Add(new ChartPoint(i, shown[i], number, FillOf(item, value, colour), TooltipOf(item, value, pairLabel), value.IsQualified)); + } + + // Bars are outlined in their colour, so a true zero is a line on the baseline — an actual point (brief §4.3) + // — while a gap draws nothing. An overlay's outline is thinner, like its fill is fainter. + var line = item.Style == ChartSeriesStyle.Line; + var stroke = line ? 2 : item.IsComparison ? 1 : BarStrokeWidth; + drawn.Add(new ChartPanelSeries( + item.Key, name, item.Style, item.IsComparison, colour, stroke, line && item.IsComparison ? 5 : 0, points)); + } + + panels.Add(new ChartPanel(group.Key, drawn, negative, positive, markedHere, values, shown, gapsHere)); + } + + var plan = new AnalysisChartPlan(labels, marked, panels); + if (plan.HasValues) + { + return plan; + } + + // Nothing to draw: say why. A price that is missing is the reason when there is one — the quantities are there — + // then data that is only coarser than the buckets; otherwise there is no data. + var gaps = series.Where(s => !s.IsComparison) + .SelectMany(s => Enumerable.Range(0, buckets.Count).Select(i => ValueAt(s, i))) + .ToList(); + var notPriced = gaps.FirstOrDefault(v => v.Gap == ChartGap.NotPriced); + var reason = notPriced is not null ? ChartEmptyReason.NotPriced + : gaps.Any(v => v.Gap == ChartGap.Unresolved) ? ChartEmptyReason.Unresolved + : ChartEmptyReason.NoData; + return plan with { EmptyReason = reason, EmptyStatus = notPriced?.Status }; + } + + /// + /// The axis label of each bucket (), with the year across years + /// (). Labels are the chart's categories, so they are + /// kept distinct: should two still collide, both get their year, and a remaining duplicate its position. + /// + public static IReadOnlyList BucketLabels(IReadOnlyList buckets) + { + ArgumentNullException.ThrowIfNull(buckets); + + var labels = buckets.Select(b => Format.BucketLabel(b, Format.SpansYears(buckets))).ToList(); + if (labels.Distinct(StringComparer.Ordinal).Count() != labels.Count) + { + labels = [.. buckets.Select(b => Format.BucketLabel(b, includeYear: true))]; + } + + var seen = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < labels.Count; i++) + { + if (!seen.Add(labels[i])) + { + labels[i] = labels[i] + " (" + (i + 1).ToString(CultureInfo.CurrentCulture) + ")"; + seen.Add(labels[i]); + } + } + + return labels; + } + + /// The value at a bucket; a series shorter than the plan is unknown there, never zero. + private static ChartValue ValueAt(AnalysisChartSeries series, int index) => + index < series.Values.Count + ? series.Values[index] + : new ChartValue(null, true, BucketStatus.Missing.Display()) { Gap = ChartGap.NoData, Status = BucketStatus.Missing.Display() }; + + private static decimal? ToDecimal(double? value) => + value is { } number && double.IsFinite(number) && Math.Abs(number) < 7.9e27 ? (decimal)number : null; + + private static string? FillOf(AnalysisChartSeries series, ChartValue value, string colour) + { + if (series.Style != ChartSeriesStyle.Bar) + { + return null; + } + + if (series.IsComparison) + { + return ChartPalette.WithAlpha(colour, value.IsQualified ? ComparisonAlpha / 2 : ComparisonAlpha); + } + + return value.IsQualified ? ChartPalette.WithAlpha(colour, QualifiedAlpha) : colour; + } + + private static string TooltipOf(AnalysisChartSeries series, ChartValue value, string? pairLabel) + { + var text = series.IsMoney ? Format.Money(value.Value, series.Currency) : Format.Quantity(value.Value, series.Unit); + if (pairLabel is not null) + { + text = pairLabel + ": " + text; + } + + return value.Note is { Length: > 0 } note ? text + " · " + note : text; + } + + /// + /// Series names made distinct (the chart library keys series by name): two meters may share a name, which is user + /// data; the second gets its position. + /// + private static List UniqueNames(IReadOnlyList series) + { + var names = new List(series.Count); + var seen = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < series.Count; i++) + { + var name = string.IsNullOrWhiteSpace(series[i].Name) ? series[i].Key : series[i].Name; + if (!seen.Add(name)) + { + name = name + " (" + (i + 1).ToString(CultureInfo.CurrentCulture) + ")"; + seen.Add(name); + } + + names.Add(name); + } + + return names; + } +} diff --git a/src/App/Analysis/AnalysisChartOptions.cs b/src/App/Analysis/AnalysisChartOptions.cs new file mode 100644 index 0000000..db49db5 --- /dev/null +++ b/src/App/Analysis/AnalysisChartOptions.cs @@ -0,0 +1,168 @@ +using System.Globalization; +using System.Text.Json; +using ApexCharts; + +namespace MeterVault.App.Analysis; + +/// +/// The chart library's options for one (D-49): a transparent background in the theme's mode, +/// straight lines that break at unknown buckets, a y-axis that reaches zero, a solid zero line when values are signed, +/// units or currency in the axis and tooltip formatters, no animation (so nothing moves for a reader who asked for +/// reduced motion) and no toolbar. +/// +/// +/// On Blazor Server the library's .NET label formatters are unavailable, so formatting happens twice by design: the +/// tooltip text of every point is formatted in .NET (, carried as the point's +/// extra), and the axis uses a small JavaScript formatter with the reader's locale and the unit +/// (). +/// +public static class AnalysisChartOptions +{ + /// Builds fresh options for ; the chart component re-keys its chart whenever they change. + /// The panel. + /// The theme's colours. + /// The reader's culture, for the axis numbers. + public static ApexChartOptions Build(ChartPanel panel, ChartPalette palette, CultureInfo culture) + { + ArgumentNullException.ThrowIfNull(panel); + ArgumentNullException.ThrowIfNull(palette); + ArgumentNullException.ThrowIfNull(culture); + + var mode = palette.IsDark ? Mode.Dark : Mode.Light; + var discrete = new List(); + for (var s = 0; s < panel.Series.Count; s++) + { + var series = panel.Series[s]; + if (series.Style != ChartSeriesStyle.Line) + { + continue; + } + + // A qualified point on a line is a hollow square: its shape says "not a plain value", not only its colour. + foreach (var point in series.Points.Where(p => p.IsQualified && p.Value is not null)) + { + discrete.Add(new MarkersDiscrete + { + SeriesIndex = s, + DataPointIndex = point.Index, + Shape = MarkerShape.Square, + Size = 5, + FillColor = palette.Surface, + StrokeColor = series.Color, + }); + } + } + + var axis = new YAxis + { + ForceNiceScale = true, + Labels = new YAxisLabels { Formatter = ChartFormatters.Axis(panel.Unit, culture) }, + }; + + // A real zero baseline: an all-positive series is measured from zero, an all-negative one up to zero. + if (!panel.HasNegative && panel.HasPositive) + { + axis.Min = 0; + } + else if (panel.HasNegative && !panel.HasPositive) + { + axis.Max = 0; + } + + return new ApexChartOptions + { + Chart = new Chart + { + Background = "transparent", + ForeColor = palette.Text, + Toolbar = new Toolbar { Show = false }, + Zoom = new Zoom { Enabled = false }, + Animations = new Animations { Enabled = false }, + RedrawOnParentResize = true, + }, + Theme = new ApexCharts.Theme { Mode = mode }, + DataLabels = new DataLabels { Enabled = false }, + Legend = new Legend { Position = LegendPosition.Top, HorizontalAlign = Align.Left }, + Grid = new Grid { BorderColor = palette.Grid, StrokeDashArray = 0 }, + Stroke = new Stroke { Curve = Curve.Straight }, + Markers = new Markers + { + Size = panel.Series.Select(s => s.Style == ChartSeriesStyle.Line ? 3d : 0d).ToList(), + StrokeColors = palette.Surface, + StrokeWidth = 2, + Discrete = discrete, + Hover = new MarkersHover { SizeOffset = 2 }, + }, + PlotOptions = new PlotOptions { Bar = new PlotOptionsBar { ColumnWidth = "70%", BorderRadius = 2 } }, + States = new States + { + Active = new StatesActive + { + AllowMultipleDataPointsSelection = false, + Filter = new StatesFilter { Type = StatesFilterType.none }, + }, + }, + Tooltip = new Tooltip + { + Enabled = true, + Shared = true, + Intersect = false, + Theme = mode, + Y = new TooltipY { Formatter = ChartFormatters.Tooltip }, + }, + Xaxis = new XAxis + { + Labels = new XAxisLabels { Rotate = -45, HideOverlappingLabels = true, Trim = false }, + Tooltip = new AxisTooltip { Enabled = false }, + }, + Yaxis = [axis], + Annotations = panel.HasNegative + ? new Annotations + { + Yaxis = + [ + new AnnotationsYAxis { Y = 0, BorderColor = palette.Baseline, BorderWidth = 1, StrokeDashArray = 0 }, + ], + } + : null, + }; + } +} + +/// The extra data a chart point carries into the browser: its tooltip text, formatted in .NET. +public sealed record ChartPointExtra(string Text); + +/// +/// JavaScript formatter functions for the chart library (strings it evaluates). Everything interpolated into them — +/// locale, unit — is written as a JSON string literal, so a unit can never break out of its string. +/// +public static class ChartFormatters +{ + /// + /// The tooltip value of a point: the text formatted in .NET (), which names the unit or + /// currency and the status in words; a plain number only if that is missing. + /// + public const string Tooltip = + "function (value, opts) { " + + "var s = opts && opts.w && opts.w.config && opts.w.config.series ? opts.w.config.series[opts.seriesIndex] : null; " + + "var p = s && s.data ? s.data[opts.dataPointIndex] : null; " + + "if (p && p.extra && p.extra.text) { return p.extra.text; } " + + "return value === null || value === undefined ? '—' : String(value); }"; + + /// + /// An axis label formatter: the number in the reader's locale (at most two decimals) and the unit or currency + /// symbol; blank for a missing value. + /// + public static string Axis(string? unit, CultureInfo culture) + { + ArgumentNullException.ThrowIfNull(culture); + + var locale = string.IsNullOrEmpty(culture.Name) ? "en" : culture.Name; + var suffix = string.IsNullOrWhiteSpace(unit) ? string.Empty : " " + unit.Trim(); + return "function (value) { if (value === null || value === undefined || !isFinite(value)) { return ''; } " + + "return new Intl.NumberFormat(" + Literal(locale) + ", { maximumFractionDigits: 2 }).format(value) + " + Literal(suffix) + "; }"; + } + + /// A JavaScript string literal (JSON-escaped, HTML-sensitive characters included). + public static string Literal(string text) => JsonSerializer.Serialize(text ?? string.Empty); +} diff --git a/src/App/Analysis/AnalysisCsvWriter.cs b/src/App/Analysis/AnalysisCsvWriter.cs new file mode 100644 index 0000000..a347f6c --- /dev/null +++ b/src/App/Analysis/AnalysisCsvWriter.cs @@ -0,0 +1,140 @@ +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"; +} diff --git a/src/App/Analysis/AnalysisDefaults.cs b/src/App/Analysis/AnalysisDefaults.cs new file mode 100644 index 0000000..fce8d8a --- /dev/null +++ b/src/App/Analysis/AnalysisDefaults.cs @@ -0,0 +1,113 @@ +using MeterVault.Core.Analysis; + +namespace MeterVault.App.Analysis; + +/// +/// What a page shows when its URL does not say (D-02, D-46): the period preset, bucket size, comparison, metric and +/// scope. A default applies only to a key that is absent; never writes a key whose value +/// equals the target page's default, so links stay short and a page never rewrites its address on load. +/// +/// +/// +/// The Overview defaults to month to date and every history page — the meter Analysis tab, an energy type's History, +/// the Analysis page (/trends), Solar and Consumables — to the last 12 months (D-02). Both compare with the +/// previous year by default (amendment A-13): "this month so far against the same days last year" is the question the +/// Overview answers, and a seasonal utility compared with the months just before would read as a trend that is only +/// the season. +/// +/// +/// A null means "the scope's natural metric": a meter's own quantity kind, a type's use, the +/// portfolio's cost. The page decides it; the URL only carries a metric somebody chose. +/// +/// +/// is the scope a page is about when its URL names none: the portfolio on the Overview and the +/// Analysis page, the meter on a meter page (), the type on an energy type page. A route that +/// implies its scope therefore never writes it, and a link that carries the period onward never carries the scope. +/// +/// +public sealed record AnalysisDefaults +{ + /// + /// is , which has no dates to default to, or + /// is a year comparison without its year. + /// + public AnalysisDefaults( + PeriodPreset period, + BucketSize bucket, + ComparisonRequest comparison, + AnalysisMetric? metric = null, + QueryScope? scope = null) + { + ArgumentNullException.ThrowIfNull(comparison); + if (period == PeriodPreset.Custom || !Enum.IsDefined(period)) + { + throw new ArgumentException("A page default is a preset, never a custom range.", nameof(period)); + } + + if (comparison.Kind == ComparisonKind.Year && comparison.Year is null) + { + throw new ArgumentException("A year comparison needs its year.", nameof(comparison)); + } + + Period = period; + Bucket = bucket; + Comparison = comparison; + Metric = metric; + Scope = scope ?? QueryScope.Portfolio; + } + + /// The Overview (/): month to date, automatic buckets, compared with the previous year. + public static AnalysisDefaults Overview { get; } = + new(PeriodPreset.MonthToDate, BucketSize.Auto, new ComparisonRequest(ComparisonKind.PreviousYear)); + + /// + /// Every history view — the meter Analysis tab, an energy type's History, /trends, Solar, Consumables: the + /// last 12 months (12 calendar buckets ending with the current partial month), automatic buckets, compared with the + /// previous year. + /// + public static AnalysisDefaults History { get; } = + new(PeriodPreset.Last12Months, BucketSize.Auto, new ComparisonRequest(ComparisonKind.PreviousYear)); + + /// The CSV export (/export/analysis.csv): the history defaults, so a link written for it is explicit about everything else. + public static AnalysisDefaults Export => History; + + public PeriodPreset Period { get; } + + public BucketSize Bucket { get; } + + public ComparisonRequest Comparison { get; } + + /// The metric; null for the scope's natural one. + public AnalysisMetric? Metric { get; } + + /// The scope the page is about when its URL names none. + public QueryScope Scope { get; } + + /// These defaults on a page whose route implies (a meter page, an energy type page). + public AnalysisDefaults ForScope(QueryScope scope) => new(Period, Bucket, Comparison, Metric, scope); + + /// These defaults with another default metric. + public AnalysisDefaults WithMetric(AnalysisMetric? metric) => new(Period, Bucket, Comparison, metric, Scope); + + /// + /// The defaults of the page at (base-relative or absolute path, query ignored): the Overview's + /// for /, the history defaults for everything else — for components outside a page (the meter search) that + /// carry the current page's period onward. + /// + public static AnalysisDefaults ForPath(string? path) + { + var text = path ?? string.Empty; + var cut = text.IndexOfAny(['?', '#']); + if (cut >= 0) + { + text = text[..cut]; + } + + if (Uri.TryCreate(text, UriKind.Absolute, out var absolute) && absolute.Scheme is "http" or "https") + { + text = absolute.AbsolutePath; + } + + return text.Trim('/').Length == 0 ? Overview : History; + } +} diff --git a/src/App/Analysis/AnalysisExport.cs b/src/App/Analysis/AnalysisExport.cs new file mode 100644 index 0000000..e7a75a6 --- /dev/null +++ b/src/App/Analysis/AnalysisExport.cs @@ -0,0 +1,270 @@ +using System.Globalization; +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.App.Analysis; + +/// The rows of an analysis export, or why the request cannot be answered (a 400 message). +/// A short invariant message for an invalid request; null when there are rows. +/// The download's file name. +/// One row per bucket and series. +public sealed record AnalysisExportResult(string? Error, string FileName, IReadOnlyList Rows) +{ + public static AnalysisExportResult Invalid(string message) => new(message, string.Empty, []); +} + +/// +/// Builds the analysis table the CSV export streams (D-55) from the same URL keys, the same +/// resolution and the same readers as the pages — so the file holds exactly the figures on +/// screen: quantities from , costs from , comparisons paired by +/// bucket. +/// +/// +/// +/// Series. A meter or a meter selection exports each meter's own series (whatever the metric: a meter measures +/// what it measures), with its cost by the meter's rule. An energy type or the portfolio exports the per-type measures +/// of the metric (consumption: total use and grid import, never added), or every measure without one. The cost metric — +/// and a category, which is analysed by cost — exports one cost series per scope (per meter for a selection). +/// +/// +/// Refused. Any URL key that the query could not read (a notice), a category asked for a quantity, the tank +/// balance (not a bucketed series), too many meters or too many buckets, and an unknown meter, type or category — each +/// is a 400 with a short message, never a 500 and never a silently different export. +/// +/// +public sealed class AnalysisExport( + AnalysisReader reader, + CostReader costs, + AnalysisPeriods periods, + IDbContextFactory contextFactory, + TimeProvider time) +{ + /// Reads what shows, as CSV rows. + public async Task PrepareAsync(AnalysisQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + if (query.Notices.Count > 0) + { + return AnalysisExportResult.Invalid(string.Join(" ", query.Notices.Select(n => n.Describe()))); + } + + if (query.Metric == AnalysisMetric.Balance) + { + return AnalysisExportResult.Invalid("The tank balance is not a bucketed series and cannot be exported; use metric=consumption."); + } + + var byCost = query.Metric == AnalysisMetric.Cost || query.Scope.Kind == QueryScopeKind.Category; + if (query.Scope.Kind == QueryScopeKind.Category && query.Metric is { } metric && metric.IsQuantity()) + { + return AnalysisExportResult.Invalid("A cost category is analysed by cost; use metric=cost."); + } + + var period = await periods.ResolveAsync(query, time.GetUtcNow(), cancellationToken).ConfigureAwait(false); + var names = await Names.LoadAsync(contextFactory, cancellationToken).ConfigureAwait(false); + + var rows = byCost + ? await CostRowsAsync(query, period, names, cancellationToken).ConfigureAwait(false) + : await QuantityRowsAsync(query, period, names, cancellationToken).ConfigureAwait(false); + return rows.Error is not null ? rows : rows with { FileName = FileName(query, period) }; + } + + private async Task QuantityRowsAsync( + AnalysisQuery query, ResolvedPeriod period, Names names, CancellationToken cancellationToken) + { + if (query.Scope.Kind == QueryScopeKind.EnergyType && !names.Types.ContainsKey(query.Scope.Id!.Value)) + { + return AnalysisExportResult.Invalid(string.Create(CultureInfo.InvariantCulture, $"Unknown energy type: {query.Scope.Id}.")); + } + + if (query.Scope.MeterIds.FirstOrDefault(id => !names.Meters.ContainsKey(id)) is var missing and > 0) + { + return AnalysisExportResult.Invalid(string.Create(CultureInfo.InvariantCulture, $"Unknown meter: {missing}.")); + } + + var request = query.ToAnalysisRequest(period)!; + var result = await reader.ReadAsync(request, cancellationToken).ConfigureAwait(false); + if (Refused(result.Refusal, result.Plan) is { } refusal) + { + return AnalysisExportResult.Invalid(refusal); + } + + var buckets = result.Plan.Buckets; + var zone = reader.Zone; + var rows = new List(); + + if (query.Scope.Kind is QueryScopeKind.Meter or QueryScopeKind.Meters) + { + foreach (var series in result.Series) + { + var cost = await MeterCostAsync(series.MeterId!.Value, period, result.Plan, cancellationToken).ConfigureAwait(false); + rows.AddRange(Rows(series, series.Name, buckets, zone, cost)); + } + + return new AnalysisExportResult(null, string.Empty, rows); + } + + var measures = query.Metric is { } metric ? AnalysisMetrics.MeasuresOf(metric) : null; + foreach (var series in result.Measures.Where(m => measures is null || (m.Key.Measure is { } measure && measures.Contains(measure)))) + { + var typeName = series.EnergyTypeId is { } typeId ? names.Types.GetValueOrDefault(typeId, string.Empty) : string.Empty; + var name = series.Key.Measure is { } measure ? typeName + " · " + measure.Display() : typeName; + rows.AddRange(Rows(series, name, buckets, zone, cost: null)); + } + + return new AnalysisExportResult(null, string.Empty, rows); + } + + private async Task CostRowsAsync( + AnalysisQuery query, ResolvedPeriod period, Names names, CancellationToken cancellationToken) + { + var rows = new List(); + BucketPlan? plan = null; + foreach (var request in query.ToCostRequests(period)) + { + // Every scope of a selection is priced in the first one's buckets, so the rows line up. + var current = await costs.ReadAsync(plan is null ? request : request with { Plan = plan }, cancellationToken).ConfigureAwait(false); + if (current.Refusal == CostRefusal.UnknownScope) + { + return AnalysisExportResult.Invalid("Unknown " + request.Scope.ToString().Replace(':', ' ') + "."); + } + + if (Refused(current.Refusal == CostRefusal.TooManyPoints ? AnalysisRefusal.TooManyPoints : AnalysisRefusal.None, current.Plan) is { } refusal) + { + return AnalysisExportResult.Invalid(refusal); + } + + plan ??= current.Plan; + + IReadOnlyList? previous = null; + if (query.Comparison.Kind != ComparisonKind.None) + { + var comparison = query.ToCostComparison(request with { Plan = current.Plan }, current.Plan); + if (comparison.Request is { } comparisonRequest) + { + previous = (await costs.ReadAsync(comparisonRequest, cancellationToken).ConfigureAwait(false)).Buckets; + } + } + + var (id, name) = CostSeries(request.Scope, names); + var buckets = current.Plan.Buckets; + for (var i = 0; i < buckets.Count && i < current.Buckets.Count; i++) + { + var amount = current.Buckets[i]; + rows.Add(new AnalysisCsvRow( + id, + name, + nameof(QuantityKind.Cost), + current.Currency, + Local(buckets[i].From, reader.Zone), + Local(buckets[i].To, reader.Zone), + reader.Zone.Id, + amount.Cost, + // Nothing booked is unknown, never "Available" beside an empty value (§4.3, FigureText.IsNothingBooked). + (FigureText.IsNothingBooked(amount) ? BucketStatus.Missing : amount.Availability).ToString(), + string.Empty, + amount.Cost, + amount.Status.ToString(), + current.Currency, + previous is not null && i < previous.Count ? previous[i].Cost : null)); + } + } + + return new AnalysisExportResult(null, string.Empty, rows); + } + + /// A meter's cost in the quantity buckets; null when the meter is not costed (generation, runtime, no rule). + private async Task MeterCostAsync(int meterId, ResolvedPeriod period, BucketPlan plan, CancellationToken cancellationToken) + { + var analysis = await costs.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meterId), period) { Plan = plan }, cancellationToken) + .ConfigureAwait(false); + return analysis.Refusal != CostRefusal.None || analysis.Meter is { Rule: MeterCostRule.None } ? null : analysis; + } + + private static IEnumerable Rows( + AnalysisSeries series, string name, IReadOnlyList buckets, TimeZoneInfo zone, CostAnalysis? cost) + { + for (var i = 0; i < buckets.Count && i < series.Values.Count; i++) + { + var value = series.Values[i]; + var amount = cost is not null && i < cost.Buckets.Count ? cost.Buckets[i] : null; + var previous = series.Comparison is { } comparison && i < comparison.Values.Count ? comparison.Values[i].Value : null; + yield return new AnalysisCsvRow( + series.Key.Id, + name, + series.Kind.ToString(), + series.Unit, + Local(buckets[i].From, zone), + Local(buckets[i].To, zone), + zone.Id, + value.Value, + value.Status.ToString(), + ProvenanceTokens(value.Provenance), + amount?.Cost, + amount?.Status.ToString(), + amount is null ? null : cost!.Currency, + previous); + } + } + + /// The set flags as identifiers joined by | (Measured|Estimated); empty for none. + private static string ProvenanceTokens(Provenance provenance) => + provenance == Provenance.None + ? string.Empty + : string.Join('|', Enum.GetValues().Where(f => f != Provenance.None && provenance.HasFlag(f))); + + private static (string Id, string Name) CostSeries(CostScope scope, Names names) => scope.Kind switch + { + CostScopeKind.EnergyType => (Token('t', scope.Id), names.Types.GetValueOrDefault(scope.Id!.Value, string.Empty)), + CostScopeKind.Meter => (Token('m', scope.Id), names.Meters.GetValueOrDefault(scope.Id!.Value, string.Empty)), + CostScopeKind.Category => (Token('c', scope.Id), names.Categories.GetValueOrDefault(scope.Id!.Value, string.Empty)), + _ => (QueryScope.Portfolio.Token, QueryScopeKind.Portfolio.Display()), + }; + + private static string Token(char prefix, int? id) => prefix + id!.Value.ToString(CultureInfo.InvariantCulture); + + private static DateTimeOffset Local(DateTimeOffset instant, TimeZoneInfo zone) => TimeZoneInfo.ConvertTime(instant, zone); + + /// The 400 message of a refused request; null when it was not refused. + private static string? Refused(AnalysisRefusal refusal, BucketPlan plan) => refusal switch + { + AnalysisRefusal.TooManySeries => string.Create( + CultureInfo.InvariantCulture, $"At most {AnalysisLimits.MaxSeries} meters can be exported side by side."), + AnalysisRefusal.TooManyPoints => string.Create( + CultureInfo.InvariantCulture, + $"bucket={AnalysisTokens.Format(plan.Size)} gives {plan.PointCount} buckets, more than {AnalysisLimits.MaxPoints}") + + (plan.Suggested is { } suggested ? "; use bucket=" + AnalysisTokens.Format(suggested) + "." : "."), + _ => null, + }; + + /// metervault-meter-42-consumption-2025-10-01-2026-09-19.csv. + private static string FileName(AnalysisQuery query, ResolvedPeriod period) + { + var scope = query.Scope.ToString().Replace(':', '-').Replace(',', '-'); + var metric = query.Metric is { } m ? AnalysisMetrics.Format(m) : query.Scope.Kind == QueryScopeKind.Category ? "cost" : "quantity"; + var last = period.HasNotStarted() ? period.LastDay : period.EffectiveLastDay(); + var first = period.FirstDay <= last ? period.FirstDay : last; + return $"metervault-{scope}-{metric}-{AnalysisTokens.FormatDate(first)}-{AnalysisTokens.FormatDate(last)}.csv"; + } + + /// The names the rows carry: meters, energy types and categories (user data, as stored). + private sealed record Names( + IReadOnlyDictionary Meters, + IReadOnlyDictionary Types, + IReadOnlyDictionary Categories) + { + public static async Task LoadAsync(IDbContextFactory factory, CancellationToken cancellationToken) + { + await using var db = await factory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var meters = await db.Meters.AsNoTracking().ToDictionaryAsync(m => m.Id, m => m.Name, cancellationToken).ConfigureAwait(false); + var types = await db.EnergyTypes.AsNoTracking().ToDictionaryAsync(t => (int)t.Id, t => t.DisplayName, cancellationToken).ConfigureAwait(false); + var categories = await db.CostCategories.AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken).ConfigureAwait(false); + return new Names(meters, types, categories); + } + } +} diff --git a/src/App/Analysis/AnalysisExportEndpoints.cs b/src/App/Analysis/AnalysisExportEndpoints.cs new file mode 100644 index 0000000..b41610d --- /dev/null +++ b/src/App/Analysis/AnalysisExportEndpoints.cs @@ -0,0 +1,57 @@ +using System.Text; + +namespace MeterVault.App.Analysis; + +/// +/// GET /export/analysis.csv (D-55): the analysis table of the URL keys the pages use — scope/id/ +/// ids, metric, period, from, to, bucket, compare — as a CSV download. +/// Build links to it with . +/// +/// +/// A UI endpoint, not part of the versioned REST API: it serves the same reader a signed-in browser already sees and +/// needs no API key, like the pages themselves. Invalid input is a 400 with a short plain-text message; nothing a user +/// can type into the URL makes it a 500. +/// +public static class AnalysisExportEndpoints +{ + public static IEndpointRouteBuilder MapAnalysisExport(this IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + endpoints.MapGet(AnalysisLinks.ExportPath, async (HttpContext http, AnalysisExport export, ILogger logger, CancellationToken ct) => + { + var query = AnalysisQuery.Parse(http.Request.Query, AnalysisDefaults.Export); + + AnalysisExportResult prepared; + try + { + prepared = await export.PrepareAsync(query, ct); + } + catch (ArgumentException ex) + { + // The readers reject a combination they cannot answer with an ArgumentException; that is the request's + // fault, not the server's. + logger.LogWarning(ex, "Analysis export refused {Query}", query); + return Results.Text("This combination of keys cannot be exported.", "text/plain; charset=utf-8", Encoding.UTF8, StatusCodes.Status400BadRequest); + } + + if (prepared.Error is { } error) + { + return Results.Text(error, "text/plain; charset=utf-8", Encoding.UTF8, StatusCodes.Status400BadRequest); + } + + return Results.Stream( + async stream => + { + // A byte-order mark, so spreadsheet programs read the umlauts of meter names as UTF-8. + await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true); + await AnalysisCsvWriter.WriteAsync(writer, prepared.Rows, ct); + }, + "text/csv; charset=utf-8", + prepared.FileName); + }) + .ExcludeFromDescription(); + + return endpoints; + } +} diff --git a/src/App/Analysis/AnalysisMetric.cs b/src/App/Analysis/AnalysisMetric.cs new file mode 100644 index 0000000..1b0272d --- /dev/null +++ b/src/App/Analysis/AnalysisMetric.cs @@ -0,0 +1,117 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Totals; + +namespace MeterVault.App.Analysis; + +/// +/// What an analysis view charts (D-47 metric=consumption|generation|export|runtime|net|cost|balance): a +/// quantity kind, the cost, or a tank's balance. The URL token is the stable identifier; the visible label comes from +/// . +/// +public enum AnalysisMetric +{ + Consumption, + Generation, + Export, + Runtime, + + /// A signed virtual result (a difference of kinds, D-26). + Net, + + /// The cost of the scope (D-34 – D-43). + Cost, + + /// A tank's level over time (consumables). + Balance, +} + +/// URL tokens and meaning of . +public static class AnalysisMetrics +{ + private static readonly (AnalysisMetric Value, string Token)[] Tokens = + [ + (AnalysisMetric.Consumption, "consumption"), + (AnalysisMetric.Generation, "generation"), + (AnalysisMetric.Export, "export"), + (AnalysisMetric.Runtime, "runtime"), + (AnalysisMetric.Net, "net"), + (AnalysisMetric.Cost, "cost"), + (AnalysisMetric.Balance, "balance"), + ]; + + /// The URL token of a metric (consumption, cost, …). + public static string Format(AnalysisMetric metric) + { + foreach (var (value, token) in Tokens) + { + if (value == metric) + { + return token; + } + } + + throw new ArgumentOutOfRangeException(nameof(metric), metric, "No URL token for this metric."); + } + + /// Parses a metric token, ignoring case and surrounding blanks; false for anything else. + public static bool TryParse(string? token, out AnalysisMetric metric) + { + metric = default; + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + var text = token.Trim(); + foreach (var (value, name) in Tokens) + { + if (string.Equals(name, text, StringComparison.OrdinalIgnoreCase)) + { + metric = value; + return true; + } + } + + return false; + } + + /// True for the quantity metrics (everything but cost and balance), which the analysis reader answers. + public static bool IsQuantity(this AnalysisMetric metric) => metric is not (AnalysisMetric.Cost or AnalysisMetric.Balance); + + /// The quantity kind a quantity metric charts; null for cost and balance. + public static QuantityKind? QuantityKindOf(AnalysisMetric metric) => metric switch + { + AnalysisMetric.Consumption => QuantityKind.Consumption, + AnalysisMetric.Generation => QuantityKind.Generation, + AnalysisMetric.Export => QuantityKind.Export, + AnalysisMetric.Runtime => QuantityKind.Runtime, + AnalysisMetric.Net => QuantityKind.Net, + _ => null, + }; + + /// + /// The per-type measures (D-22) a quantity metric shows for an energy type or the portfolio: consumption is the + /// household use and, separately, the billed grid import (never added to each other); net has no measure, being a + /// virtual meter's own result. + /// + public static IReadOnlyList MeasuresOf(AnalysisMetric metric) => metric switch + { + AnalysisMetric.Consumption => [TotalsMeasure.Use, TotalsMeasure.GridImport], + AnalysisMetric.Generation => [TotalsMeasure.Generation], + AnalysisMetric.Export => [TotalsMeasure.Export], + AnalysisMetric.Runtime => [TotalsMeasure.Runtime], + _ => [], + }; + + /// The metric a quantity kind is charted under; has none. + public static AnalysisMetric? MetricOf(QuantityKind kind) => kind switch + { + QuantityKind.Consumption => AnalysisMetric.Consumption, + QuantityKind.Generation => AnalysisMetric.Generation, + QuantityKind.Export => AnalysisMetric.Export, + QuantityKind.Runtime => AnalysisMetric.Runtime, + QuantityKind.Net => AnalysisMetric.Net, + QuantityKind.Cost => AnalysisMetric.Cost, + _ => null, + }; +} diff --git a/src/App/Analysis/AnalysisNavigation.cs b/src/App/Analysis/AnalysisNavigation.cs new file mode 100644 index 0000000..e7168f0 --- /dev/null +++ b/src/App/Analysis/AnalysisNavigation.cs @@ -0,0 +1,189 @@ +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using Microsoft.AspNetCore.Components; + +namespace MeterVault.App.Analysis; + +/// One breadcrumb: its text (a name is user data) and its link; null for the current page. +public sealed record Crumb(string Text, string? Href); + +/// +/// Where the analysis components lead (D-46, D-48, D-51, brief §4.3): replacing the page's analysis state from the +/// toolbar, drilling into a bucket, going to the latest data, and the breadcrumb trail — each keeping the period. +/// +public static class AnalysisNavigation +{ + /// + /// Writes into the current page's address, replacing the history entry (D-46: toolbar and + /// tab changes replace, drill-downs push). Keys equal to the page defaults are removed; other keys (tab) stay. + /// + public static void Replace(NavigationManager navigation, AnalysisQuery query, AnalysisDefaults defaults) + { + ArgumentNullException.ThrowIfNull(navigation); + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(defaults); + + navigation.NavigateTo(navigation.GetUriWithQueryParameters(query.ToNavigationParameters(defaults)), replace: true); + } + + /// + /// The address of the current page showing (for a drill-down, which pushes a new history + /// entry: Nav.NavigateTo(AnalysisNavigation.UriFor(Nav, next, Defaults))). + /// + public static string UriFor(NavigationManager navigation, AnalysisQuery query, AnalysisDefaults defaults) + { + ArgumentNullException.ThrowIfNull(navigation); + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(defaults); + + return navigation.GetUriWithQueryParameters(query.ToNavigationParameters(defaults)); + } + + /// The local days a bucket stands for, both inclusive: a bucket cut at now stands for its whole unit (its ). + public static (DateOnly First, DateOnly Last) DaysOf(AnalysisBucket bucket) + { + ArgumentNullException.ThrowIfNull(bucket); + + var end = bucket.NominalEndDay ?? bucket.EndDay; + return (bucket.FirstDay, end > bucket.FirstDay ? end.AddDays(-1) : bucket.FirstDay); + } + + /// + /// The finer bucket sizes a drill into may use, most useful first: a year opens its months, + /// a month its days (or weeks, when the data resolves weeks but not days), a week its days; a day has none. + /// + public static IReadOnlyList FinerSizes(BucketSize size) => size switch + { + BucketSize.Year => [BucketSize.Month], + BucketSize.Month => [BucketSize.Day, BucketSize.Week], + BucketSize.Week => [BucketSize.Day], + _ => [], + }; + + /// + /// The drill-down of a chart bucket (D-51): the same scope, metric and comparison over the bucket's days, in the next + /// finer bucket the data supports. Null when there is none — a day, or data too coarse for anything finer (a monthly + /// import) — and the page opens the bucket's records instead (). + /// + /// The page's analysis state. + /// The clicked bucket. + /// + /// The coarsest resolution among the charted series (); + /// null when unknown, which allows any finer size. + /// + /// + /// A named-year comparison (year:2024) needs a calendar year; drilling below a year turns it into the same + /// period a year earlier, which is what a year comparison of a month means. + /// + public static AnalysisQuery? DrillInto(AnalysisQuery query, AnalysisBucket bucket, ResolutionClass? coarsestResolution = null) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(bucket); + + var floor = coarsestResolution is { } resolution ? BucketPlanner.MinimumSizeFor(resolution) : BucketSize.Day; + BucketSize? finer = null; + foreach (var size in FinerSizes(bucket.Size)) + { + if (size >= floor) + { + finer = size; + break; + } + } + + var (first, last) = DaysOf(bucket); + if (finer is not { } next || !PeriodResolver.IsValidCustomRange(first, last)) + { + return null; + } + + var drilled = query.WithCustomRange(first, last).WithBucket(next); + var wholeYear = first is { Month: 1, Day: 1 } && last.Month == 12 && last.Day == 31 && first.Year == last.Year; + return query.Comparison.Kind == ComparisonKind.Year && !wholeYear + ? drilled.WithComparison(new ComparisonRequest(ComparisonKind.PreviousYear)) + : drilled; + } + + /// The meter's Normalized data tab filtered to a bucket's days (D-50, D-51), keeping the rest of the analysis state. + public static string NormalizedData(int meterId, AnalysisQuery query, AnalysisBucket bucket) + { + ArgumentNullException.ThrowIfNull(query); + + var (first, last) = DaysOf(bucket); + var target = PeriodResolver.IsValidCustomRange(first, last) ? query.WithCustomRange(first, last) : query; + return MeterLinks.Detail(meterId, MeterLinks.TabNormalized, null, target); + } + + /// + /// "Go to latest data" (brief §4.3): a period of the same kind ending with the latest data — its month for a month + /// preset, its calendar year for a year preset, the 12 or 24 months up to it for those, a custom range of the same + /// length ending on the last available day. Null without availability. + /// + public static AnalysisQuery? LatestData(AnalysisQuery query, AvailableRange? availability) + { + ArgumentNullException.ThrowIfNull(query); + + if (availability is null) + { + return null; + } + + var lastDay = availability.LastDay; + var month = new DateOnly(lastDay.Year, lastDay.Month, 1); + var monthEnd = month.AddMonths(1).AddDays(-1); + var (first, last) = query.Period switch + { + PeriodPreset.MonthToDate or PeriodPreset.LastMonth => (month, monthEnd), + PeriodPreset.YearToDate or PeriodPreset.PreviousYear => (new DateOnly(lastDay.Year, 1, 1), new DateOnly(lastDay.Year, 12, 31)), + PeriodPreset.Last24Months => (month.AddMonths(-23), monthEnd), + PeriodPreset.Custom when query.From is { } from && query.To is { } to && to >= from => + (lastDay.AddDays(from.DayNumber - to.DayNumber), lastDay), + _ => (month.AddMonths(-11), monthEnd), + }; + + if (first < PeriodResolver.MinSupportedDate) + { + first = PeriodResolver.MinSupportedDate; + } + + return PeriodResolver.IsValidCustomRange(first, last) ? query.WithCustomRange(first, last) : null; + } + + /// + /// The breadcrumb trail (D-48, brief §3.1): Overview → energy type → meter, each link carrying the period, bucket, + /// comparison and metric of ; the last crumb is the current page and has no link. + /// + /// The current page's analysis state; null carries nothing. + /// The energy type (id, name), when the page is inside one. + /// The meter (id, name), when the page is a meter's. + /// A label for the current page below them (a tab, a specialized view); null when the last of the above is the page. + public static IReadOnlyList Breadcrumbs( + AnalysisQuery? query, + (int Id, string Name)? energyType = null, + (int Id, string Name)? meter = null, + string? current = null) + { + var trail = new List { new(Strings.Nav_Overview, AnalysisLinks.Overview(query)) }; + if (energyType is { } type) + { + trail.Add(new Crumb(type.Name, AnalysisLinks.EnergyType(type.Id, null, query))); + } + + if (meter is { } m) + { + trail.Add(new Crumb(m.Name, MeterLinks.Analysis(m.Id, query))); + } + + if (!string.IsNullOrWhiteSpace(current)) + { + trail.Add(new Crumb(current, null)); + } + else + { + trail[^1] = trail[^1] with { Href = null }; + } + + return trail; + } +} diff --git a/src/App/Analysis/AnalysisPeriods.cs b/src/App/Analysis/AnalysisPeriods.cs new file mode 100644 index 0000000..f4b1ca8 --- /dev/null +++ b/src/App/Analysis/AnalysisPeriods.cs @@ -0,0 +1,53 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; + +namespace MeterVault.App.Analysis; + +/// +/// Resolves an the way every page and the CSV export do (D-01, D-02, D-19): against the +/// captured now, in the zone the readers cut days in, and — for all only — over the scope's availability, which +/// is the one thing resolving has to read. +/// +/// +/// Availability follows what the query shows: the cost scope's (billed meters plus manual costs) for the cost metric and +/// for a category, the quantity scope's otherwise (D-19). Nothing is read for any other preset. +/// +public sealed class AnalysisPeriods(AnalysisReader reader, CostReader costs) +{ + /// The zone periods are resolved in: the readers' (MeterVault__TimeZone). + public TimeZoneInfo Zone => reader.Zone; + + /// Resolves as of . + public async Task ResolveAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + var availability = query.Period == PeriodPreset.AllHistory + ? await AvailabilityAsync(query, now, cancellationToken).ConfigureAwait(false) + : null; + return query.Resolve(now, reader.Zone, availability); + } + + /// What the query's scope has data for as of , capped at now (D-19); null without any. + public async Task AvailabilityAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + if (query.Metric == AnalysisMetric.Cost || query.Scope.ToAnalysisScope() is not { } scope) + { + var ranges = new List(); + foreach (var costScope in query.Scope.ToCostScopes()) + { + var availability = await costs.GetAvailabilityAsync(costScope, now, cancellationToken).ConfigureAwait(false); + ranges.Add(availability.Range); + } + + return AvailableRange.Union(ranges, costs.Zone); + } + + var quantity = await reader.GetAvailabilityAsync(scope, now, cancellationToken).ConfigureAwait(false); + return quantity.Quantity; + } +} diff --git a/src/App/Analysis/AnalysisQuery.cs b/src/App/Analysis/AnalysisQuery.cs new file mode 100644 index 0000000..ece372e --- /dev/null +++ b/src/App/Analysis/AnalysisQuery.cs @@ -0,0 +1,725 @@ +using System.Globalization; +using System.Text; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Primitives; + +namespace MeterVault.App.Analysis; + +/// The URL keys of the analysis state (D-02, D-46, D-47). Stable invariant identifiers, never localized. +public static class AnalysisUrlKeys +{ + public const string Scope = "scope"; + public const string Id = "id"; + public const string Ids = "ids"; + public const string Metric = "metric"; + public const string Period = "period"; + public const string From = "from"; + public const string To = "to"; + public const string Bucket = "bucket"; + public const string Compare = "compare"; + + /// Every analysis key, in the order links write them. + public static IReadOnlyList All { get; } = [Scope, Id, Ids, Metric, Period, From, To, Bucket, Compare]; +} + +/// Which parts of an a URL is written with. +[Flags] +public enum AnalysisQueryParts +{ + None = 0, + + /// scope, id, ids. + Scope = 1, + + /// metric. + Metric = 2, + + /// period, from, to. + Period = 4, + + /// bucket. + Bucket = 8, + + /// compare. + Comparison = 16, + + /// What a link carries onward to another page (D-47): everything but the scope, which the target's route names. + Carry = Metric | Period | Bucket | Comparison, + + All = Scope | Carry, +} + +/// +/// The analysis state of a page, parsed from its URL (D-02, D-46, D-47, brief §4.1): period preset (or custom dates), +/// bucket size, comparison, metric and scope — immutable and compared by value, so a page reloads its analysis only +/// when this value changes. +/// +/// +/// +/// Reading. reads the keys of . A key +/// that is absent takes the page default (); a key with an invalid value takes the default +/// too and adds a notice — a hand-edited or stale link never breaks the page (D-02). Tokens +/// are read case-insensitively; previous-year / previous-period are accepted for prev-year / +/// prev-period. from/to (yyyy-MM-dd, inclusive) make a custom range when period is +/// custom or absent, and are ignored beside another preset. Explicit meter selections keep at most +/// meters. +/// +/// +/// Writing. , and +/// write the canonical tokens and omit every key equal to the target page's default; a custom range is written +/// as from and to alone. Links to another page carry (period, +/// bucket, comparison, metric), because the target's route names its scope. +/// +/// +/// Resolving. turns the preset into a through +/// , once per load, against a captured now and the instance zone; all spans the +/// scope's availability (D-19). , and +/// build the reader requests in one place, so pages and the CSV export ask identically. +/// +/// +/// are not part of the value: two URLs that resolve to the same state are equal, whatever was +/// wrong with them. The With… helpers return a query without notices — a choice made in the toolbar is clean. +/// +/// +public sealed class AnalysisQuery : IEquatable +{ + private AnalysisQuery( + PeriodPreset period, + DateOnly? from, + DateOnly? to, + BucketSize bucket, + ComparisonRequest comparison, + AnalysisMetric? metric, + QueryScope scope, + IReadOnlyList notices) + { + Period = period; + From = from; + To = to; + Bucket = bucket; + Comparison = comparison; + Metric = metric; + Scope = scope; + Notices = notices; + } + + /// The period preset; with /. + public PeriodPreset Period { get; } + + /// The first local day of a custom range (inclusive); null for a preset. + public DateOnly? From { get; } + + /// The last local day of a custom range (inclusive); null for a preset. + public DateOnly? To { get; } + + public BucketSize Bucket { get; } + + public ComparisonRequest Comparison { get; } + + /// The chosen metric; null for the scope's natural one (the page decides). + public AnalysisMetric? Metric { get; } + + public QueryScope Scope { get; } + + /// What in the URL was not used as written; not part of equality. + public IReadOnlyList Notices { get; } + + public bool IsCustom => Period == PeriodPreset.Custom; + + /// The page defaults as a query: what a page shows with no analysis keys in its URL. + public static AnalysisQuery Default(AnalysisDefaults defaults) + { + ArgumentNullException.ThrowIfNull(defaults); + + return new AnalysisQuery(defaults.Period, null, null, defaults.Bucket, defaults.Comparison, defaults.Metric, defaults.Scope, []); + } + + /// + /// Parses the analysis keys of a URL — absolute (), + /// base-relative, or a query string starting with ?. Anything without a ? has no keys. + /// + public static AnalysisQuery Parse(string? uriOrQuery, AnalysisDefaults defaults) => + Parse(QueryHelpers.ParseQuery(QueryOf(uriOrQuery)), defaults); + + /// Parses the analysis keys of . + public static AnalysisQuery Parse(Uri uri, AnalysisDefaults defaults) + { + ArgumentNullException.ThrowIfNull(uri); + + return Parse(uri.IsAbsoluteUri ? uri.Query : uri.OriginalString, defaults); + } + + /// Parses the analysis keys of a query collection (, a parsed query). + public static AnalysisQuery Parse(IEnumerable> query, AnalysisDefaults defaults) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(defaults); + + var keys = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, values) in query) + { + if (key is not null) + { + keys[key] = keys.TryGetValue(key, out var existing) ? StringValues.Concat(existing, values) : values; + } + } + + var reader = new Reader(keys); + var notices = new List(); + + var (period, from, to) = ParsePeriod(reader, defaults, notices); + var bucket = ParseToken(reader, AnalysisUrlKeys.Bucket, defaults.Bucket, AnalysisTokens.TryParseBucket, AnalysisQueryNoticeKind.InvalidBucket, notices); + var comparison = ParseComparison(reader, defaults, notices); + var metric = ParseMetric(reader, defaults, notices); + var scope = ParseScope(reader, defaults, notices); + + return new AnalysisQuery(period, from, to, bucket, comparison, metric, scope, notices); + } + + /// This query with a preset period. + /// : use . + public AnalysisQuery WithPeriod(PeriodPreset preset) + { + if (preset == PeriodPreset.Custom || !Enum.IsDefined(preset)) + { + throw new ArgumentException("A custom period needs its dates; use WithCustomRange.", nameof(preset)); + } + + return new AnalysisQuery(preset, null, null, Bucket, Comparison, Metric, Scope, []); + } + + /// This query with a custom range of local days, both inclusive. + /// The range fails : check it first (the toolbar applies a range only once it is valid). + public AnalysisQuery WithCustomRange(DateOnly first, DateOnly last) + { + if (!PeriodResolver.IsValidCustomRange(first, last)) + { + throw new ArgumentException("A custom range needs a first and last day in order, within the supported dates.", nameof(first)); + } + + return new AnalysisQuery(PeriodPreset.Custom, first, last, Bucket, Comparison, Metric, Scope, []); + } + + public AnalysisQuery WithBucket(BucketSize bucket) => + Enum.IsDefined(bucket) + ? new AnalysisQuery(Period, From, To, bucket, Comparison, Metric, Scope, []) + : throw new ArgumentOutOfRangeException(nameof(bucket), bucket, "Unknown bucket size."); + + /// A comparison without its year, which no URL can hold. + public AnalysisQuery WithComparison(ComparisonRequest comparison) + { + ArgumentNullException.ThrowIfNull(comparison); + if (comparison.Kind == ComparisonKind.Year && comparison.Year is null) + { + throw new ArgumentException("A year comparison needs its year.", nameof(comparison)); + } + + return new AnalysisQuery(Period, From, To, Bucket, comparison, Metric, Scope, []); + } + + /// This query with a metric; null for the scope's natural one. + public AnalysisQuery WithMetric(AnalysisMetric? metric) => new(Period, From, To, Bucket, Comparison, metric, Scope, []); + + public AnalysisQuery WithScope(QueryScope scope) + { + ArgumentNullException.ThrowIfNull(scope); + + return new AnalysisQuery(Period, From, To, Bucket, Comparison, Metric, scope, []); + } + + /// + /// The URL parameters of this query, in canonical order and tokens, leaving out every key equal to + /// (the target page's) and every part not in . + /// + public IReadOnlyList> ToQueryParameters(AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.All) + { + ArgumentNullException.ThrowIfNull(defaults); + + var list = new List>(6); + if (parts.HasFlag(AnalysisQueryParts.Scope) && !Scope.Equals(defaults.Scope)) + { + list.Add(new(AnalysisUrlKeys.Scope, Scope.Token)); + if (Scope.Kind == QueryScopeKind.Meters) + { + list.Add(new(AnalysisUrlKeys.Ids, string.Join(',', Scope.MeterIds.Select(id => id.ToString(CultureInfo.InvariantCulture))))); + } + else if (Scope.Id is { } id) + { + list.Add(new(AnalysisUrlKeys.Id, id.ToString(CultureInfo.InvariantCulture))); + } + } + + if (parts.HasFlag(AnalysisQueryParts.Metric) && Metric is { } metric && metric != defaults.Metric) + { + list.Add(new(AnalysisUrlKeys.Metric, AnalysisMetrics.Format(metric))); + } + + if (parts.HasFlag(AnalysisQueryParts.Period)) + { + if (IsCustom) + { + // from/to imply custom, so the preset key is left out. + list.Add(new(AnalysisUrlKeys.From, AnalysisTokens.FormatDate(From!.Value))); + list.Add(new(AnalysisUrlKeys.To, AnalysisTokens.FormatDate(To!.Value))); + } + else if (Period != defaults.Period) + { + list.Add(new(AnalysisUrlKeys.Period, AnalysisTokens.Format(Period))); + } + } + + if (parts.HasFlag(AnalysisQueryParts.Bucket) && Bucket != defaults.Bucket) + { + list.Add(new(AnalysisUrlKeys.Bucket, AnalysisTokens.Format(Bucket))); + } + + if (parts.HasFlag(AnalysisQueryParts.Comparison) && !Comparison.Equals(defaults.Comparison)) + { + list.Add(new(AnalysisUrlKeys.Compare, AnalysisTokens.Format(Comparison))); + } + + return list; + } + + /// + /// Every analysis key of for + /// : + /// the value to write, or null to remove a key that equals the default — so updating the current page's URL keeps + /// its other keys (tab) and drops stale ones (from/to after leaving a custom range). + /// + public IReadOnlyDictionary ToNavigationParameters(AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.All) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var key in KeysOf(parts)) + { + result[key] = null; + } + + foreach (var (key, value) in ToQueryParameters(defaults, parts)) + { + result[key] = value; + } + + return result; + } + + /// + /// with this query's parameters appended after its own (D-47: existing keys first), leaving + /// out what equals — the target page's. Links carry + /// by default: the target's route names its own scope. + /// + public string AppendTo(string url, AnalysisDefaults defaults, AnalysisQueryParts parts = AnalysisQueryParts.Carry) + { + ArgumentNullException.ThrowIfNull(url); + + var parameters = ToQueryParameters(defaults, parts); + if (parameters.Count == 0) + { + return url; + } + + var builder = new StringBuilder(url); + var separator = url.Contains('?', StringComparison.Ordinal) ? '&' : '?'; + foreach (var (key, value) in parameters) + { + builder.Append(separator).Append(key).Append('=').Append(Escape(value)); + separator = '&'; + } + + return builder.ToString(); + } + + /// + /// Resolves the period once, against the captured in the instance + /// (D-01, D-03). all spans (D-19) — the quantity or cost scope's, whichever + /// the page shows — and is the "no history" range without it. + /// + public ResolvedPeriod Resolve(DateTimeOffset now, TimeZoneInfo zone, AvailableRange? availability = null) + { + ArgumentNullException.ThrowIfNull(zone); + + return Period switch + { + PeriodPreset.Custom => PeriodResolver.Resolve(PeriodPreset.Custom, From, To, now, zone), + PeriodPreset.AllHistory => PeriodResolver.Resolve( + PeriodPreset.AllHistory, null, null, now, zone, availability?.FirstDay, availability?.LastDay), + _ => PeriodResolver.Resolve(Period, null, null, now, zone), + }; + } + + /// + /// The quantity request of this query over (bucket and comparison included), or null for + /// a category scope, which is analysed by cost. + /// + /// The period from . + /// For a type or portfolio: also one series per meter ("individual meters"). + public AnalysisRequest? ToAnalysisRequest(ResolvedPeriod period, bool includeMeterSeries = false) + { + ArgumentNullException.ThrowIfNull(period); + + return Scope.ToAnalysisScope() is { } scope + ? new AnalysisRequest(scope, period) { Bucket = Bucket, Comparison = Comparison, IncludeMeterSeries = includeMeterSeries } + : null; + } + + /// + /// The cost requests of this query over : one for the portfolio, a type, a meter or a + /// category, one per meter for a selection. + /// + /// The period from . + /// + /// Buckets to price in — a quantity result's , or the first cost result's for the + /// rest of a selection — so cost and quantity share their buckets; null lets the cost reader plan from + /// . + /// + /// For the portfolio: also the category composition (D-42). + public IReadOnlyList ToCostRequests(ResolvedPeriod period, BucketPlan? plan = null, bool includeCategories = false) + { + ArgumentNullException.ThrowIfNull(period); + + return + [ + .. Scope.ToCostScopes().Select(scope => new CostAnalysisRequest(scope, period) + { + Bucket = Bucket, + Plan = plan, + IncludeCategories = includeCategories && scope.Kind == CostScopeKind.Portfolio, + }), + ]; + } + + /// + /// The cost request for this query's comparison (D-06) of an already priced request: the + /// comparison period, priced in the images of the current buckets (), so + /// bucket i of the result compares with bucket i of the current one. The cost reader has no comparison of its own; + /// quantities get theirs from the analysis reader (). + /// + /// The current cost request (its scope and period). + /// The plan the current result was priced in (). + public CostComparisonRequest ToCostComparison(CostAnalysisRequest current, BucketPlan currentPlan) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(currentPlan); + + var resolution = ComparisonResolver.Resolve(current.Period, Comparison); + if (!resolution.IsApplicable) + { + return new CostComparisonRequest(resolution, null, []); + } + + var pairs = ComparisonResolver.PairBuckets(current.Period, resolution.Period, currentPlan.Buckets); + var plan = new BucketPlan(currentPlan.Size, currentPlan.Size, [.. pairs.Select(p => p.Comparison)], pairs.Count, Refused: false, Suggested: null); + var request = new CostAnalysisRequest(current.Scope, resolution.Period.ToResolvedPeriod(current.Period)) + { + Plan = plan, + MaxPoints = current.MaxPoints, + IncludeCategories = current.IncludeCategories, + }; + return new CostComparisonRequest(resolution, request, pairs); + } + + public bool Equals(AnalysisQuery? other) => + other is not null + && Period == other.Period + && From == other.From + && To == other.To + && Bucket == other.Bucket + && Comparison.Equals(other.Comparison) + && Metric == other.Metric + && Scope.Equals(other.Scope); + + public override bool Equals(object? obj) => Equals(obj as AnalysisQuery); + + public override int GetHashCode() => HashCode.Combine(Period, From, To, Bucket, Comparison, Metric, Scope); + + public static bool operator ==(AnalysisQuery? left, AnalysisQuery? right) => left is null ? right is null : left.Equals(right); + + public static bool operator !=(AnalysisQuery? left, AnalysisQuery? right) => !(left == right); + + /// Every key written, defaults included — for logs. + public override string ToString() + { + var period = IsCustom + ? AnalysisTokens.FormatDate(From!.Value) + ".." + AnalysisTokens.FormatDate(To!.Value) + : AnalysisTokens.Format(Period); + var metric = Metric is { } m ? AnalysisMetrics.Format(m) : "natural"; + return string.Create( + CultureInfo.InvariantCulture, + $"{Scope} {metric} {period} {AnalysisTokens.Format(Bucket)} {AnalysisTokens.Format(Comparison)}"); + } + + private static (PeriodPreset Period, DateOnly? From, DateOnly? To) ParsePeriod(Reader reader, AnalysisDefaults defaults, List notices) + { + var periodToken = reader.First(AnalysisUrlKeys.Period); + var fromToken = reader.First(AnalysisUrlKeys.From); + var toToken = reader.First(AnalysisUrlKeys.To); + + if (periodToken is not null) + { + if (!AnalysisTokens.TryParsePeriod(periodToken, out var preset)) + { + notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidPeriod, AnalysisUrlKeys.Period, periodToken)); + return (defaults.Period, null, null); + } + + // Beside another preset, from/to mean nothing and are ignored. + if (preset != PeriodPreset.Custom) + { + return (preset, null, null); + } + } + else if (fromToken is null && toToken is null) + { + return (defaults.Period, null, null); + } + + if (AnalysisTokens.TryParseCustomRange(fromToken, toToken, out var first, out var last)) + { + return (PeriodPreset.Custom, first, last); + } + + notices.Add(new AnalysisQueryNotice( + AnalysisQueryNoticeKind.InvalidRange, AnalysisUrlKeys.From + "/" + AnalysisUrlKeys.To, (fromToken ?? string.Empty) + "/" + (toToken ?? string.Empty))); + return (defaults.Period, null, null); + } + + private static ComparisonRequest ParseComparison(Reader reader, AnalysisDefaults defaults, List notices) + { + var token = reader.First(AnalysisUrlKeys.Compare); + if (token is null) + { + return defaults.Comparison; + } + + if (AnalysisTokens.TryParseComparison(token, out var comparison)) + { + return comparison; + } + + notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidComparison, AnalysisUrlKeys.Compare, token)); + return defaults.Comparison; + } + + private static AnalysisMetric? ParseMetric(Reader reader, AnalysisDefaults defaults, List notices) + { + var token = reader.First(AnalysisUrlKeys.Metric); + if (token is null) + { + return defaults.Metric; + } + + if (AnalysisMetrics.TryParse(token, out var metric)) + { + return metric; + } + + notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidMetric, AnalysisUrlKeys.Metric, token)); + return defaults.Metric; + } + + private delegate bool TokenParser(string? token, out T value); + + private static T ParseToken( + Reader reader, string key, T fallback, TokenParser parse, AnalysisQueryNoticeKind invalid, List notices) + { + var token = reader.First(key); + if (token is null) + { + return fallback; + } + + if (parse(token, out var value)) + { + return value; + } + + notices.Add(new AnalysisQueryNotice(invalid, key, token)); + return fallback; + } + + private static QueryScope ParseScope(Reader reader, AnalysisDefaults defaults, List notices) + { + // Without a scope key, ids mean nothing: the route (or the page default) names the scope. + var token = reader.First(AnalysisUrlKeys.Scope); + if (token is null) + { + return defaults.Scope; + } + + if (!QueryScope.TryParseKind(token, out var kind)) + { + notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, token)); + return defaults.Scope; + } + + switch (kind) + { + case QueryScopeKind.Portfolio: + return QueryScope.Portfolio; + + case QueryScopeKind.Meters: + return ParseSelection(reader, token, notices) ?? defaults.Scope; + + default: + var idToken = reader.First(AnalysisUrlKeys.Id); + if (TryParseId(idToken, out var id)) + { + return kind switch + { + QueryScopeKind.EnergyType => QueryScope.ForEnergyType(id), + QueryScopeKind.Category => QueryScope.ForCategory(id), + _ => QueryScope.ForMeter(id), + }; + } + + notices.Add(idToken is null + ? new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, token) + : new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidId, AnalysisUrlKeys.Id, idToken)); + return defaults.Scope; + } + } + + /// + /// ids=3,5,9 (or repeated ids, or a single id): the valid ids in order, distinct, at most + /// ; null when none is valid. + /// + private static QueryScope? ParseSelection(Reader reader, string scopeToken, List notices) + { + var tokens = reader.All(AnalysisUrlKeys.Ids) + .SelectMany(v => v.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + .ToList(); + if (tokens.Count == 0 && reader.First(AnalysisUrlKeys.Id) is { } single) + { + tokens.Add(single.Trim()); + } + + var ids = new List(); + var invalid = new List(); + foreach (var item in tokens) + { + if (!TryParseId(item, out var id)) + { + invalid.Add(item); + } + else if (!ids.Contains(id)) + { + ids.Add(id); + } + } + + if (invalid.Count > 0) + { + notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidId, AnalysisUrlKeys.Ids, string.Join(',', invalid))); + } + + if (ids.Count > AnalysisLimits.MaxSeries) + { + notices.Add(new AnalysisQueryNotice( + AnalysisQueryNoticeKind.TooManyMeters, AnalysisUrlKeys.Ids, ids.Count.ToString(CultureInfo.InvariantCulture))); + ids = ids.Take(AnalysisLimits.MaxSeries).ToList(); + } + + if (ids.Count == 0) + { + if (invalid.Count == 0) + { + notices.Add(new AnalysisQueryNotice(AnalysisQueryNoticeKind.InvalidScope, AnalysisUrlKeys.Scope, scopeToken)); + } + + return null; + } + + return QueryScope.ForMeters(ids); + } + + private static bool TryParseId(string? token, out int id) + { + id = 0; + return token is not null + && int.TryParse(token.AsSpan().Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out id) + && id > 0; + } + + private static IEnumerable KeysOf(AnalysisQueryParts parts) + { + if (parts.HasFlag(AnalysisQueryParts.Scope)) + { + yield return AnalysisUrlKeys.Scope; + yield return AnalysisUrlKeys.Id; + yield return AnalysisUrlKeys.Ids; + } + + if (parts.HasFlag(AnalysisQueryParts.Metric)) + { + yield return AnalysisUrlKeys.Metric; + } + + if (parts.HasFlag(AnalysisQueryParts.Period)) + { + yield return AnalysisUrlKeys.Period; + yield return AnalysisUrlKeys.From; + yield return AnalysisUrlKeys.To; + } + + if (parts.HasFlag(AnalysisQueryParts.Bucket)) + { + yield return AnalysisUrlKeys.Bucket; + } + + if (parts.HasFlag(AnalysisQueryParts.Comparison)) + { + yield return AnalysisUrlKeys.Compare; + } + } + + /// The query part of a URL (from its ?, without a fragment), or empty. + private static string QueryOf(string? uriOrQuery) + { + if (string.IsNullOrEmpty(uriOrQuery)) + { + return string.Empty; + } + + var start = uriOrQuery.IndexOf('?', StringComparison.Ordinal); + if (start < 0) + { + return string.Empty; + } + + var end = uriOrQuery.IndexOf('#', start); + return end < 0 ? uriOrQuery[start..] : uriOrQuery[start..end]; + } + + /// Escapes a value, keeping the : of year:2025 and the commas of an id list readable. + private static string Escape(string value) => + Uri.EscapeDataString(value).Replace("%3A", ":", StringComparison.Ordinal).Replace("%2C", ",", StringComparison.Ordinal); + + /// Case-insensitive access to a parsed query: the first non-blank value of a key, or all of them. + private sealed class Reader(Dictionary keys) + { + public string? First(string key) + { + if (!keys.TryGetValue(key, out var values)) + { + return null; + } + + foreach (var value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + public IEnumerable All(string key) => + keys.TryGetValue(key, out var values) ? values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!) : []; + } +} diff --git a/src/App/Analysis/AnalysisQueryNotice.cs b/src/App/Analysis/AnalysisQueryNotice.cs new file mode 100644 index 0000000..3a93326 --- /dev/null +++ b/src/App/Analysis/AnalysisQueryNotice.cs @@ -0,0 +1,58 @@ +using System.Globalization; + +namespace MeterVault.App.Analysis; + +/// Why part of an analysis URL was not used as written (D-02: an invalid token falls back to the default with a notice). +public enum AnalysisQueryNoticeKind +{ + /// period is not a known preset. + InvalidPeriod, + + /// A custom range whose from/to are missing, malformed, out of order or outside the supported dates. + InvalidRange, + + /// bucket is not a known size. + InvalidBucket, + + /// compare is not a known comparison. + InvalidComparison, + + /// metric is not a known metric. + InvalidMetric, + + /// scope is not a known scope, or names no usable id. + InvalidScope, + + /// An id/ids entry is not a positive whole number. + InvalidId, + + /// More meters than can be charted side by side were selected; the first ones are kept. + TooManyMeters, +} + +/// +/// One part of an analysis URL that was not used as written: what was wrong, under which key, and the raw value. The +/// page shows it (localized through ); the CSV export answers it with a 400. +/// +/// What was wrong. +/// The URL key (period, ids, …). +/// The raw value as it appeared (data, never shown untrusted as markup). +public sealed record AnalysisQueryNotice(AnalysisQueryNoticeKind Kind, string Key, string? Value) +{ + /// A short invariant English sentence, for logs and the export's 400 responses. + public string Describe() => Kind switch + { + AnalysisQueryNoticeKind.InvalidPeriod => Quote("Unknown period"), + AnalysisQueryNoticeKind.InvalidRange => Quote("Invalid custom range (from/to must be yyyy-MM-dd dates in order)"), + AnalysisQueryNoticeKind.InvalidBucket => Quote("Unknown bucket"), + AnalysisQueryNoticeKind.InvalidComparison => Quote("Unknown comparison"), + AnalysisQueryNoticeKind.InvalidMetric => Quote("Unknown metric"), + AnalysisQueryNoticeKind.InvalidScope => Quote("Unknown or incomplete scope"), + AnalysisQueryNoticeKind.InvalidId => Quote("Invalid id"), + AnalysisQueryNoticeKind.TooManyMeters => string.Create( + CultureInfo.InvariantCulture, $"At most {Infrastructure.Analysis.AnalysisLimits.MaxSeries} meters can be compared ('{Key}')."), + _ => Quote("Invalid value"), + }; + + private string Quote(string text) => string.Create(CultureInfo.InvariantCulture, $"{text}: {Key}='{Value}'."); +} diff --git a/src/App/Analysis/AnalysisTableModel.cs b/src/App/Analysis/AnalysisTableModel.cs new file mode 100644 index 0000000..0da8bb9 --- /dev/null +++ b/src/App/Analysis/AnalysisTableModel.cs @@ -0,0 +1,324 @@ +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.App.Analysis; + +/// One formatted figure of a table: the number, its text (with unit or currency, "—" when unknown) and its status. +public sealed record TableFigure(double? Value, string Text, FigureStatus Status) +{ + /// A quantity with its unit. + public static TableFigure Of(BucketValue value, string? unit, Func? meterName = null) + { + var status = FigureText.Of(value, meterName); + var number = status.IsKnown ? value.Value : null; + return new TableFigure(number, Format.Quantity(number, unit), status); + } + + /// A cost in . + public static TableFigure Of(CostAmount amount, string currency) + { + var status = FigureText.Of(amount); + var number = status.IsKnown ? amount.Cost : null; + return new TableFigure(number, Format.Money(number, currency), status); + } +} + +/// +/// A series of the analysis table (brief §7.2): its values per bucket and in total, and optionally its cost and its +/// comparison — each formatted once, in the reader's culture, with its status in words. +/// +public sealed record AnalysisTableSeries +{ + private AnalysisTableSeries(string key, string name, IReadOnlyList values, TableFigure? total, bool isMoney) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(name); + + Key = key; + Name = name; + Values = values; + Total = total; + IsMoney = isMoney; + } + + public string Key { get; init; } + + /// The column header (a meter's name is user data, never translated). + public string Name { get; init; } + + /// One figure per bucket. + public IReadOnlyList Values { get; init; } + + /// The period total from the reader; null to show none (it is never added up here: a formula may not be additive). + public TableFigure? Total { get; init; } + + /// True when the values are money, so no separate cost column applies. + public bool IsMoney { get; init; } + + /// The cost per bucket, when priced (a meter's cost by its rule). + public IReadOnlyList? Costs { get; init; } + + public TableFigure? CostTotal { get; init; } + + /// The comparison per paired bucket (A-10), when one was requested. + public IReadOnlyList? Comparison { get; init; } + + public TableFigure? ComparisonTotal { get; init; } + + /// The change over the matched coverage (D-07) for the total row; computed from the totals when absent. + public Change? TotalChange { get; init; } + + /// Formats the size of a change (with unit or currency). + public Func FormatDifference { get; init; } = v => Format.Number(v, 2); + + /// Whether a rise is good news (D-08). + public ChangePolarity Polarity { get; init; } = ChangePolarity.HigherIsWorse; + + /// False when the buckets do not add up to the total (a ratio, a formula with a constant, D-27). + public bool IsAdditive { get; init; } = true; + + /// A quantity series with its unit. + /// A stable key. + /// The column header. + /// The unit of every value. + /// One value per bucket. + /// The period total; null to show none. + /// Names the meter a derived value misses (a source without data, a loop); "#id" without it. + public static AnalysisTableSeries ForValues( + string key, string name, string? unit, IReadOnlyList values, BucketValue? total = null, Func? meterName = null) + { + ArgumentNullException.ThrowIfNull(values); + + return new AnalysisTableSeries( + key, name, [.. values.Select(v => TableFigure.Of(v, unit, meterName))], total is null ? null : TableFigure.Of(total, unit, meterName), isMoney: false) + { + FormatDifference = v => Format.Quantity(v, unit), + }; + } + + /// A cost series (metric cost, a category): the values are money. + public static AnalysisTableSeries ForCosts(string key, string name, string currency, IReadOnlyList amounts, CostAmount? total = null) + { + ArgumentNullException.ThrowIfNull(amounts); + + return new AnalysisTableSeries(key, name, [.. amounts.Select(a => TableFigure.Of(a, currency))], total is null ? null : TableFigure.Of(total, currency), isMoney: true) + { + FormatDifference = v => Format.Money(v, currency), + }; + } + + /// + /// A reader series with its total, polarity, additivity and — when one was read — its comparison and the change over + /// the matched coverage. + /// + /// The series. + /// The column header; the meter's name or the measure's wording by default. + /// Names the meter a derived value misses (a source without data, a loop); "#id" without it. + public static AnalysisTableSeries ForSeries(AnalysisSeries series, string? name = null, Func? meterName = null) + { + ArgumentNullException.ThrowIfNull(series); + + var table = ForValues(series.Key.Id, name ?? AnalysisChartSeries.NameOf(series), series.Unit, series.Values, series.Total, meterName) with + { + Polarity = ChangePolarities.For(series.Kind), + IsAdditive = series.IsAdditive, + }; + + return series.Comparison is { } comparison + ? table.WithComparison(comparison.Values, comparison.Total, series.Unit, comparison.Change, meterName) + : table; + } + + /// This series with a comparison in the same unit. + public AnalysisTableSeries WithComparison( + IReadOnlyList values, BucketValue? total, string? unit, Change? totalChange = null, Func? meterName = null) + { + ArgumentNullException.ThrowIfNull(values); + + return this with + { + Comparison = [.. values.Select(v => TableFigure.Of(v, unit, meterName))], + ComparisonTotal = total is null ? null : TableFigure.Of(total, unit, meterName), + TotalChange = totalChange, + }; + } + + /// This (money) series with its comparison priced in the paired buckets. + public AnalysisTableSeries WithComparisonCosts(IReadOnlyList amounts, CostAmount? total, string currency) + { + ArgumentNullException.ThrowIfNull(amounts); + + return this with + { + Comparison = [.. amounts.Select(a => TableFigure.Of(a, currency))], + ComparisonTotal = total is null ? null : TableFigure.Of(total, currency), + }; + } + + /// This quantity series with its cost per bucket (a meter's cost by its rule). + public AnalysisTableSeries WithCosts(IReadOnlyList amounts, CostAmount? total, string currency) + { + ArgumentNullException.ThrowIfNull(amounts); + + return this with + { + Costs = [.. amounts.Select(a => TableFigure.Of(a, currency))], + CostTotal = total is null ? null : TableFigure.Of(total, currency), + }; + } +} + +/// What a table column shows. +public enum AnalysisTableColumnKind +{ + Value, + Status, + Cost, + CostStatus, + Comparison, + Change, +} + +/// A column: its series, what it shows, its header, and (with several series) the series it belongs to. +public sealed record AnalysisTableColumn(string SeriesKey, AnalysisTableColumnKind Kind, string Header, string? SubHeader) +{ + /// Numbers are right-aligned. + public bool IsNumeric => Kind is AnalysisTableColumnKind.Value or AnalysisTableColumnKind.Cost + or AnalysisTableColumnKind.Comparison or AnalysisTableColumnKind.Change; +} + +/// A cell: its text, an optional second line (the reason, the compared bucket), a CSS class and whether it is unknown. +public sealed record AnalysisTableCell(string Text, string? Secondary = null, string? CssClass = null, bool IsUnknown = false); + +/// A row: one bucket (or the total), its label and one cell per column. +public sealed record AnalysisTableRow(AnalysisBucket? Bucket, string Label, IReadOnlyList Cells, bool IsTotal, bool IsQualified); + +/// +/// The analysis table (brief §7.2) as data: one row per bucket and a total row, each series with its value, status in +/// words, optional cost and cost status, optional comparison and change. It is the chart's accessible alternative, so it +/// says in words what the chart only marks. +/// +/// +/// A change is stated per bucket only where both figures are complete — a partial bucket against a whole one is not a +/// like-for-like change (D-07); the total row takes the reader's change over the matched coverage. Unknown values read +/// "—", never 0. +/// +public sealed record AnalysisTableModel(IReadOnlyList Columns, IReadOnlyList Rows) +{ + /// Builds the table. + /// The buckets, oldest first. + /// The series. + /// The comparison buckets paired with , to name each row's compared bucket. + /// Adds the total row. + public static AnalysisTableModel Build( + IReadOnlyList buckets, + IReadOnlyList series, + IReadOnlyList? pairs = null, + bool includeTotal = true) + { + ArgumentNullException.ThrowIfNull(buckets); + ArgumentNullException.ThrowIfNull(series); + + var several = series.Count > 1; + var columns = new List(); + foreach (var item in series) + { + var sub = several ? item.Name : null; + columns.Add(new(item.Key, AnalysisTableColumnKind.Value, item.Name.Length > 0 ? item.Name : Strings.Common_Value, null)); + columns.Add(new(item.Key, AnalysisTableColumnKind.Status, Strings.Common_Status, sub)); + if (item.Costs is not null) + { + columns.Add(new(item.Key, AnalysisTableColumnKind.Cost, Strings.AnalysisTable_Cost, sub)); + columns.Add(new(item.Key, AnalysisTableColumnKind.CostStatus, Strings.AnalysisTable_PriceCoverage, sub)); + } + + if (item.Comparison is not null) + { + columns.Add(new(item.Key, AnalysisTableColumnKind.Comparison, Strings.AnalysisTable_Comparison, sub)); + columns.Add(new(item.Key, AnalysisTableColumnKind.Change, Strings.AnalysisTable_Change, sub)); + } + } + + var labels = AnalysisChartPlan.BucketLabels(buckets); + var rows = new List(buckets.Count + 1); + for (var i = 0; i < buckets.Count; i++) + { + var cells = new List(columns.Count); + var qualified = false; + var pairLabel = pairs is not null && i < pairs.Count ? Format.BucketLabel(pairs[i].Comparison, includeYear: true) : null; + foreach (var item in series) + { + var value = At(item.Values, i); + qualified |= value.Status.IsQualified; + AddCells(cells, item, value, AtOrNull(item.Costs, i), AtOrNull(item.Comparison, i), pairLabel, null); + } + + rows.Add(new AnalysisTableRow(buckets[i], labels[i], cells, IsTotal: false, qualified)); + } + + if (includeTotal && buckets.Count > 0) + { + var cells = new List(columns.Count); + var qualified = false; + foreach (var item in series) + { + var total = item.Total ?? Unknown; + qualified |= item.Total is not null && total.Status.IsQualified; + AddCells(cells, item, total, item.Costs is null ? null : item.CostTotal ?? Unknown, item.Comparison is null ? null : item.ComparisonTotal ?? Unknown, null, item.TotalChange); + } + + rows.Add(new AnalysisTableRow(null, Strings.AnalysisTable_Total, cells, IsTotal: true, qualified)); + } + + return new AnalysisTableModel(columns, rows); + } + + /// Half a cent: a money difference that displays as zero is no change. + private const double MoneyTolerance = 0.005; + + /// A figure nothing is known about. + private static TableFigure Unknown { get; } = + new(null, Format.Unknown, new FigureStatus(false, false, true, BucketStatus.Missing.Display(), null, string.Empty)); + + private static TableFigure At(IReadOnlyList figures, int index) => index < figures.Count ? figures[index] : Unknown; + + private static TableFigure? AtOrNull(IReadOnlyList? figures, int index) => figures is null ? null : At(figures, index); + + private static void AddCells( + List cells, + AnalysisTableSeries series, + TableFigure value, + TableFigure? cost, + TableFigure? comparison, + string? pairLabel, + Change? change) + { + cells.Add(new AnalysisTableCell(value.Text, null, value.Status.IsQualified ? "mv-qualified" : null, value.Value is null)); + cells.Add(new AnalysisTableCell(value.Status.Summary, value.Status.Detail)); + if (series.Costs is not null) + { + var figure = cost ?? Unknown; + cells.Add(new AnalysisTableCell(figure.Text, null, figure.Status.IsQualified ? "mv-qualified" : null, figure.Value is null)); + cells.Add(new AnalysisTableCell(figure.Status.Status, figure.Status.Detail)); + } + + if (series.Comparison is not null) + { + var figure = comparison ?? Unknown; + cells.Add(new AnalysisTableCell(figure.Text, pairLabel, figure.Status.IsQualified ? "mv-qualified" : null, figure.Value is null)); + + var stated = change is { IsAvailable: true } ? change : ChangeOf(value, figure, series.IsMoney ? MoneyTolerance : Change.Tolerance); + var polarity = series.IsMoney ? ChangePolarities.ForCost(value.Value, figure.Value) : series.Polarity; + var tone = ChangeDisplay.Tone(stated, polarity); + cells.Add(new AnalysisTableCell(Format.ChangeText(stated, series.FormatDifference), null, ChangeDisplay.CssClass(tone), !stated.IsAvailable)); + } + } + + /// The change between two complete figures; unavailable when either is incomplete or unknown. + private static Change ChangeOf(TableFigure current, TableFigure previous, double tolerance) => + current.Status.IsComplete && previous.Status.IsComplete + ? Change.Between(current.Value, previous.Value, tolerance) + : Change.Unavailable; +} diff --git a/src/App/Analysis/AttentionItems.cs b/src/App/Analysis/AttentionItems.cs new file mode 100644 index 0000000..dc6583f --- /dev/null +++ b/src/App/Analysis/AttentionItems.cs @@ -0,0 +1,462 @@ +using System.Globalization; +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; + +namespace MeterVault.App.Analysis; + +/// How urgent an attention item is. Shown with an icon and words, never colour alone. +public enum AttentionSeverity +{ + /// Worth knowing (analysis being prepared, rows after now, a calculation to confirm). + Info, + + /// A figure is incomplete until it is fixed (a missing price, a stale source). + Warning, + + /// A figure cannot be computed until it is fixed (an invalid calculation). + Error, +} + +/// One attention item (D-53): a localized one-liner and at most one targeted action. +/// An invariant identity (kind, meter, text) — duplicates from the quantity and the cost reader collapse. +/// How urgent it is. +/// The one-liner, in the reader's language; user data (names) as it is. +/// The action's label, or null when there is nothing to do here. +/// Where the action leads. +public sealed record AttentionItem(string Key, AttentionSeverity Severity, string Text, string? ActionText, string? ActionHref); + +/// +/// The names attention items speak of: meters, energy types and cost categories by id (user data, never translated), with a neutral +/// fallback ("Meter #12") for an id nobody named. +/// +public sealed class AttentionNames +{ + private readonly IReadOnlyDictionary _meters; + private readonly IReadOnlyDictionary _energyTypes; + private readonly IReadOnlyDictionary _categories; + + public AttentionNames( + IReadOnlyDictionary? meters = null, + IReadOnlyDictionary? energyTypes = null, + IReadOnlyDictionary? categories = null) + { + _meters = meters ?? new Dictionary(); + _energyTypes = energyTypes ?? new Dictionary(); + _categories = categories ?? new Dictionary(); + } + + /// + /// The meter names a quantity result and a cost result carry — series, classification, virtual sources at any depth, + /// priced lines — plus the given energy type names. + /// + public static AttentionNames From(AnalysisResult? result, CostAnalysis? costs = null, IReadOnlyDictionary? energyTypes = null) + { + var meters = new Dictionary(); + if (result is not null) + { + foreach (var series in result.Series) + { + Add(meters, series.MeterId, series.Name); + AddContributions(meters, series.Contributions); + } + + foreach (var entry in result.Classification) + { + Add(meters, entry.MeterId, entry.Name); + } + } + + var categories = new Dictionary(); + if (costs is not null) + { + foreach (var line in costs.Lines) + { + Add(meters, line.MeterId, line.Name); + } + + foreach (var category in (costs.Composition?.Categories ?? []).Concat(costs.Category is { } own ? [own] : [])) + { + Add(categories, category.CategoryId, category.Name); + } + } + + return new AttentionNames(meters, energyTypes, categories); + } + + /// The meter's name, or "Meter #id". + public string Meter(int? id) => + id is { } key && _meters.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name) + ? name + : Loc.F(Strings.Attention_MeterFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?"); + + /// The energy type's name, or "Energy type #id". + public string EnergyType(int? id) => + id is { } key && _energyTypes.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name) + ? name + : Loc.F(Strings.Attention_EnergyTypeFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?"); + + /// The cost category's name, or "Category #id". + public string Category(int? id) => + id is { } key && _categories.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name) + ? name + : Loc.F(Strings.Attention_CategoryFallback, id?.ToString(CultureInfo.CurrentCulture) ?? "?"); + + /// A meter id's name for value details, or null when unknown. + public string? MeterOrNull(int id) => _meters.TryGetValue(id, out var name) ? name : null; + + private static void AddContributions(Dictionary meters, IReadOnlyList contributions) + { + foreach (var contribution in contributions) + { + Add(meters, contribution.MeterId, contribution.Name); + AddContributions(meters, contribution.Nested); + } + } + + private static void Add(Dictionary meters, int? id, string? name) + { + if (id is { } key && !string.IsNullOrWhiteSpace(name)) + { + meters.TryAdd(key, name); + } + } +} + +/// +/// Turns the readers' attention codes (D-53) — and — into +/// one-liners with one targeted action each: a missing price opens the tariff editor prefilled for its scope, component +/// and first uncovered month (D-52); a calculation to fix opens the meter's Calculation tab; a stale source its Sources +/// tab; rows after now its Normalized data around those days; a possible overlap the energy type's Meters tab; a +/// configuration conflict the meter editor. A kind this code does not know still gets its worded kind, without action. +/// +/// +/// Items are ordered by severity (errors first), then as the readers reported them; items with the same text for the same +/// meter collapse (the cost reader repeats the quantity reader's problems). +/// +public static class AttentionItems +{ + /// Builds the items. + /// The quantity reader's problems (). + /// The cost reader's items () — and its go into . + /// The names to speak of. + /// The page's analysis state; links into meter pages carry its period. + public static IReadOnlyList Build( + IEnumerable? problems, + IEnumerable? costAttention, + AttentionNames names, + AnalysisQuery? query = null) + { + ArgumentNullException.ThrowIfNull(names); + + var items = new List(); + foreach (var problem in problems ?? []) + { + if (problem is not null) + { + items.Add(ForProblem(problem, names, query)); + } + } + + foreach (var attention in costAttention ?? []) + { + if (attention is not null) + { + items.Add(ForCost(attention, names, query)); + } + } + + var seen = new HashSet(StringComparer.Ordinal); + return + [ + .. items + .Select((item, index) => (Item: item, Index: index)) + .Where(x => seen.Add(x.Item.Key)) + .OrderByDescending(x => x.Item.Severity) + .ThenBy(x => x.Index) + .Select(x => x.Item), + ]; + } + + /// One quantity problem. + public static AttentionItem ForProblem(AnalysisProblem problem, AttentionNames names, AnalysisQuery? query = null) + { + ArgumentNullException.ThrowIfNull(problem); + ArgumentNullException.ThrowIfNull(names); + + var meter = names.Meter(problem.MeterId); + switch (problem.Kind) + { + case AnalysisProblemKind.AnalysisPending: + return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_AnalysisPending, meter)); + + case AnalysisProblemKind.UnknownMeter: + return Item(problem.Kind, problem.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_UnknownMeter, meter)); + + case AnalysisProblemKind.InvalidDefinition: + // With the validator's finding the item says what is wrong (D-26), not only that something is. + var invalid = problem.Virtual is { } finding + ? Loc.F(Strings.Attention_InvalidDefinitionBecause, meter, VirtualReason(finding, names)) + : Loc.F(Strings.Attention_InvalidDefinition, meter); + return Item( + problem.Kind, problem.MeterId, AttentionSeverity.Error, invalid, + Strings.Attention_EditCalculation, CalculationLink(problem.MeterId, query)); + + case AnalysisProblemKind.MalformedDefinition: + return Item( + problem.Kind, problem.MeterId, AttentionSeverity.Error, Loc.F(Strings.Attention_MalformedDefinition, meter), + Strings.Attention_EditCalculation, CalculationLink(problem.MeterId, query)); + + case AnalysisProblemKind.LegacyDefinition: + return Item( + problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_LegacyDefinition, meter), + Strings.Attention_ConfirmCalculation, CalculationLink(problem.MeterId, query)); + + case AnalysisProblemKind.LegacyNeedsConfiguration: + return Item( + problem.Kind, problem.MeterId, AttentionSeverity.Error, Loc.F(Strings.Attention_LegacyNeedsConfiguration, meter), + Strings.Attention_SetUpCalculation, CalculationLink(problem.MeterId, query)); + + case AnalysisProblemKind.RecordedAfterNow: + return RecordedAfterNow(problem, meter, query); + + case AnalysisProblemKind.StaleSource: + return Item( + problem.Kind, problem.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_StaleSource, meter), + Strings.Attention_CheckSource, MeterLink(problem.MeterId, MeterLinks.TabSources, null, query)); + + case AnalysisProblemKind.TotalsProblem: + var other = problem.Totals?.OtherMeterId ?? (problem.MeterIds.Count > 0 ? problem.MeterIds[0] : null); + var reason = problem.Totals is { } totals ? TotalsReason(totals) : null; + var text = (other, reason) switch + { + (null, null) => Loc.F(Strings.Attention_TotalsProblem, meter), + (null, { } why) => Loc.F(Strings.Attention_TotalsProblemBecause, meter, why), + ({ } id, null) => Loc.F(Strings.Attention_TotalsProblemWith, meter, names.Meter(id)), + ({ } id, { } why) => Loc.F(Strings.Attention_TotalsProblemWithBecause, meter, names.Meter(id), why), + }; + return Item( + problem.Kind, problem.MeterId, AttentionSeverity.Warning, text, + Strings.Attention_EditMeter, MeterLink(problem.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query)); + + case AnalysisProblemKind.PossibleOverlap: + return PossibleOverlap(problem, names, meter, query); + + default: + return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, problem.MeterId is null ? problem.Kind.Display() : meter + ": " + problem.Kind.Display()); + } + } + + /// One cost attention item. + public static AttentionItem ForCost(CostAttention attention, AttentionNames names, AnalysisQuery? query = null) + { + ArgumentNullException.ThrowIfNull(attention); + ArgumentNullException.ThrowIfNull(names); + + var meter = names.Meter(attention.MeterId); + var key = "cost:" + attention.Kind; + switch (attention.Kind) + { + case CostAttentionKind.MissingPrice when attention.Price is { } price: + return MissingPrice(price, names); + + case CostAttentionKind.UnverifiedTariffUnit: + return Item(key, attention.MeterId, AttentionSeverity.Info, Strings.Attention_UnverifiedTariffUnit, Strings.Attention_OpenTariffs, TariffLinks.Path); + + case CostAttentionKind.ManualCostAfterToday: + return Item(key, null, AttentionSeverity.Info, Loc.F(Strings.Attention_ManualCostAfterToday, attention.ManualCostIds.Count)); + + case CostAttentionKind.ManualCostCurrency: + return Item(key, null, AttentionSeverity.Info, Loc.F(Strings.Attention_ManualCostCurrency, attention.ManualCostIds.Count)); + + case CostAttentionKind.VirtualNotCosted: + return Item( + key, attention.MeterId, AttentionSeverity.Warning, Loc.F(Strings.Attention_VirtualNotCosted, meter), + Strings.Attention_EditCalculation, CalculationLink(attention.MeterId, query)); + + case CostAttentionKind.BillingConfiguration: + var billing = attention.Totals is { } problem + ? Loc.F(Strings.Attention_BillingConfigurationBecause, meter, TotalsReason(problem, problem.OtherMeterId is { } otherId ? names.Meter(otherId) : null)) + : Loc.F(Strings.Attention_BillingConfiguration, meter); + return Item( + key, attention.MeterId, AttentionSeverity.Warning, billing, + Strings.Attention_EditMeter, MeterLink(attention.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query)); + + case CostAttentionKind.PriceChangeInsideInterval when attention is { FirstMonth: { } first, LastMonth: { } last }: + return Item( + key, attention.MeterId, AttentionSeverity.Warning, + Loc.F(Strings.Attention_PriceChangeInsideInterval, meter, Format.MonthYear(first), Format.MonthYear(last)), + Strings.Attention_OpenTariffs, TariffLinks.Path); + + case CostAttentionKind.BillingBasisGap when attention is { FirstMonth: { } first, LastMonth: { } last }: + return Item( + key, attention.MeterId, AttentionSeverity.Warning, + Loc.F(Strings.Attention_BillingBasisGap, meter, Format.MonthYear(first), Format.MonthYear(last)), + Strings.Attention_EditMeter, MeterLink(attention.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query)); + + case CostAttentionKind.CategoryPricesNothing: + // A category of calculated views or generation prices nothing (D-39, D-42): name the members and where + // to change the membership, never "no data yet" (A-22). + var members = attention.MeterIds.Count > 0 ? attention.MeterIds : attention.MeterId is { } only ? [only] : []; + return Item( + key + ":" + attention.CategoryId?.ToString(CultureInfo.InvariantCulture), attention.MeterId, AttentionSeverity.Info, + Loc.F(Strings.Attention_CategoryPricesNothing, names.Category(attention.CategoryId), string.Join(", ", members.Select(id => names.Meter(id)))), + Strings.Attention_EditCategories, CategoriesPath); + + default: + return Item(key, attention.MeterId, AttentionSeverity.Info, attention.MeterId is null ? attention.Kind.Display() : meter + ": " + attention.Kind.Display()); + } + } + + /// + /// A price a figure needed and did not get (D-38): the scope it is missing for, the component and the first month + /// that lacks it, with the tariff deep link (D-52). A missing feed-in price is an optional credit. + /// + public static AttentionItem MissingPrice(MissingPrice price, AttentionNames names) + { + ArgumentNullException.ThrowIfNull(price); + ArgumentNullException.ThrowIfNull(names); + + var scope = price.MeterId is { } meterId + ? names.Meter(meterId) + : price.Scope switch + { + TariffScope.Meter => names.Meter(price.ScopeId), + TariffScope.EnergyType => names.EnergyType(price.ScopeId), + _ => Strings.Attention_AllEnergyTypes, + }; + var component = price.Component.Display(); + var month = Format.MonthYear(price.FirstMonth); + + // A unit mismatch says what does not fit (D-37): the currency, a base price's period, or the meter's unit. + var (text, severity, action) = price.Reason switch + { + CostStatus.NotPriced => (Loc.F(Strings.Attention_PriceNotSetUp, scope, component), AttentionSeverity.Warning, Strings.Attention_AddTariff), + CostStatus.UnitMismatch when price.Issue == TariffUnitIssue.CurrencyMismatch => + (Loc.F(Strings.Attention_PriceCurrencyMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff), + CostStatus.UnitMismatch when price.Component == TariffComponent.BasePrice => + (Loc.F(Strings.Attention_BasePriceUnitMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff), + CostStatus.UnitMismatch => (Loc.F(Strings.Attention_PriceUnitMismatch, scope, component, month), AttentionSeverity.Warning, Strings.Attention_FixTariff), + _ when price.IsCredit => (Loc.F(Strings.Attention_CreditMissing, scope, component, month), AttentionSeverity.Info, Strings.Attention_AddTariff), + _ => (Loc.F(Strings.Attention_PriceMissing, scope, component, month), AttentionSeverity.Warning, Strings.Attention_AddTariff), + }; + + var key = string.Create( + CultureInfo.InvariantCulture, + $"price:{price.Reason}:{price.Scope}:{price.ScopeId}:{price.MeterId}:{price.Component}:{price.FirstMonth:yyyy-MM}"); + return new AttentionItem(key, severity, text, action, TariffLinks.For(price)); + } + + private static AttentionItem RecordedAfterNow(AnalysisProblem problem, string meter, AnalysisQuery? query) + { + if (problem.AfterNow is not { } block) + { + return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_RecordedAfterNowPlain, meter)); + } + + var text = Loc.F(Strings.Attention_RecordedAfterNow, meter, Format.DateRange(block.FirstDay, block.LastDay)); + string? link = null; + if (PeriodResolver.IsValidCustomRange(block.FirstDay, block.LastDay)) + { + var range = (query ?? AnalysisQuery.Default(AnalysisDefaults.History)).WithCustomRange(block.FirstDay, block.LastDay); + link = MeterLinks.Detail(block.MeterId, MeterLinks.TabNormalized, null, range); + } + + return Item(problem.Kind, block.MeterId, AttentionSeverity.Info, text, link is null ? null : Strings.Attention_ShowRows, link); + } + + private static AttentionItem PossibleOverlap(AnalysisProblem problem, AttentionNames names, string meter, AnalysisQuery? query) + { + if (problem.Hint is not { } hint) + { + return Item(problem.Kind, problem.MeterId, AttentionSeverity.Info, Loc.F(Strings.Attention_PossibleOverlapPlain, meter), + Strings.Attention_EditMeter, MeterLink(problem.MeterId, MeterLinks.TabAnalysis, MeterLinks.ActionEdit, query)); + } + + var other = names.Meter(hint.OtherMeterId); + var text = hint.Kind switch + { + OverlapHintKind.NotLinkedBelowTotalLoad => Loc.F(Strings.Attention_AssumedBelowTotalLoad, meter, other), + OverlapHintKind.GridImportNotLinkedToTotalLoad => Loc.F(Strings.Attention_GridImportNotLinked, meter, other), + _ => meter + ": " + hint.Kind.Display(), + }; + return Item( + problem.Kind, hint.MeterId, AttentionSeverity.Info, text, + Strings.Attention_ManageMeters, AnalysisLinks.EnergyType(hint.EnergyTypeId, AnalysisLinks.EnergyTabMeters, query)); + } + + /// + /// Why a calculation is invalid, in words (D-26): the finding's sentence, and in brackets what it is about — the + /// meters it names (the loop as a path), or the units and kinds that do not fit. + /// + public static string VirtualReason(VirtualProblem problem, AttentionNames names) + { + ArgumentNullException.ThrowIfNull(names); + return VirtualReasonText(problem, names); + } + + /// + /// without the meters the finding names, for a page that + /// lists them itself as links beside the sentence (the meter's Calculation tab): the same words everywhere. + /// + public static string VirtualReasonWithoutMeters(VirtualProblem problem) => VirtualReasonText(problem, null); + + private static string VirtualReasonText(VirtualProblem problem, AttentionNames? names) + { + ArgumentNullException.ThrowIfNull(problem); + + var detail = problem.Kind switch + { + VirtualProblemKind.DependencyCycle when names is not null && problem.MeterIds.Count > 0 => + string.Join(" → ", problem.MeterIds.Select(id => names.Meter(id))), + VirtualProblemKind.UnitMismatch or VirtualProblemKind.ResultUnitMismatch or VirtualProblemKind.IndicatorNeedsUnit + when problem.Values.Count > 0 => string.Join(", ", problem.Values), + VirtualProblemKind.KindMismatch or VirtualProblemKind.ResultKindMismatch or VirtualProblemKind.ResultKindRequired + or VirtualProblemKind.ResultKindUnsupported when problem.Values.Count > 0 => string.Join(", ", problem.Values.Select(KindWord)), + VirtualProblemKind.Syntax or VirtualProblemKind.NoReferences => null, + _ when names is not null && problem.MeterIds.Count > 0 => string.Join(", ", problem.MeterIds.Distinct().Select(id => names.Meter(id))), + _ => null, + }; + + var sentence = problem.Kind.Display(); + return detail is null ? sentence : sentence + " (" + detail + ")"; + } + + /// What contradicts itself in the totals configuration (D-22, D-23), with the role it is about. + public static string TotalsReason(TotalsProblem problem, string? otherMeter = null) + { + ArgumentNullException.ThrowIfNull(problem); + + var sentence = problem.Kind.Display(); + var detail = problem.Role is { } role ? role.Display() : otherMeter; + return detail is null ? sentence : sentence + " (" + detail + ")"; + } + + /// A quantity kind the validator names by its token, in words ("mixed" too); anything else as it is. + private static string KindWord(string value) => MeterEditing.MeterEditorText.KindWord(value); + + /// The cost category editor. + public const string CategoriesPath = "/admin/categories"; + + private static string? CalculationLink(int? meterId, AnalysisQuery? query) => MeterLink(meterId, MeterLinks.TabCalculation, null, query); + + private static string? MeterLink(int? meterId, string tab, string? action, AnalysisQuery? query) => + meterId is { } id ? MeterLinks.Detail(id, tab, action, query) : null; + + private static AttentionItem Item( + AnalysisProblemKind kind, int? meterId, AttentionSeverity severity, string text, string? actionText = null, string? href = null) => + Item("problem:" + kind, meterId, severity, text, actionText, href); + + private static AttentionItem Item( + string kind, int? meterId, AttentionSeverity severity, string text, string? actionText = null, string? href = null) + { + var key = string.Create(CultureInfo.InvariantCulture, $"{kind}:{meterId}:{text}"); + return new AttentionItem(key, severity, text, href is null ? null : actionText, href); + } +} diff --git a/src/App/Analysis/ChangeDisplay.cs b/src/App/Analysis/ChangeDisplay.cs new file mode 100644 index 0000000..d989f0c --- /dev/null +++ b/src/App/Analysis/ChangeDisplay.cs @@ -0,0 +1,106 @@ +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; + +namespace MeterVault.App.Analysis; + +/// Which direction of a change is good news for a metric (D-08, brief §4.2). +public enum ChangePolarity +{ + /// More is worse: consumption, runtime, cost. + HigherIsWorse, + + /// More is better: generation, export. + HigherIsBetter, + + /// Neither: a signed net result, an indicator, a tank level, a cost that is a credit. + Neutral, +} + +/// How a change is coloured: good, bad or neither. Never shown by colour alone — the words and the arrow carry it. +public enum ChangeTone +{ + Neutral, + Good, + Bad, +} + +/// The polarity of a metric's changes (D-08: more consumption is not good, more generation is). +public static class ChangePolarities +{ + /// The polarity of a quantity kind; net results and indicators are neutral. + public static ChangePolarity For(QuantityKind kind) => kind switch + { + QuantityKind.Consumption or QuantityKind.Runtime or QuantityKind.Cost => ChangePolarity.HigherIsWorse, + QuantityKind.Generation or QuantityKind.Export => ChangePolarity.HigherIsBetter, + _ => ChangePolarity.Neutral, + }; + + /// The polarity of a toolbar metric; net and tank level are neutral. + public static ChangePolarity For(AnalysisMetric metric) => metric switch + { + AnalysisMetric.Consumption or AnalysisMetric.Runtime or AnalysisMetric.Cost => ChangePolarity.HigherIsWorse, + AnalysisMetric.Generation or AnalysisMetric.Export => ChangePolarity.HigherIsBetter, + _ => ChangePolarity.Neutral, + }; + + /// + /// A cost rising is worse — unless either side is a credit (a negative cost, a feed-in larger than the charges): + /// then "more" and "less" have no settled meaning and the change is neutral. + /// + public static ChangePolarity ForCost(double? current, double? previous) => + current < 0 || previous < 0 ? ChangePolarity.Neutral : ChangePolarity.HigherIsWorse; +} + +/// +/// A change in words (D-08): the absolute difference always, the percentage where it applies ("percentage not +/// applicable" otherwise), and the direction as a word — so a change is readable without its colour. +/// +public static class ChangeDisplay +{ + /// Good, bad or neutral for ; neutral when unknown or unchanged. + public static ChangeTone Tone(Change change, ChangePolarity polarity) + { + ArgumentNullException.ThrowIfNull(change); + + if (!change.IsAvailable || change.Direction == 0 || polarity == ChangePolarity.Neutral) + { + return ChangeTone.Neutral; + } + + return (change.Direction > 0) == (polarity == ChangePolarity.HigherIsBetter) ? ChangeTone.Good : ChangeTone.Bad; + } + + /// + /// "12 kWh more (+4.5 %)", "3.50 € less (-2.0 %)", "12 kWh more (percentage not applicable)", "No change", or + /// "No comparison" when either value is unknown. + /// + /// The change. + /// Formats the size of the difference (a quantity with its unit, money). + public static string Words(Change change, Func formatMagnitude) + { + ArgumentNullException.ThrowIfNull(change); + ArgumentNullException.ThrowIfNull(formatMagnitude); + + if (change.Absolute is not { } difference) + { + return Strings.Change_Unavailable; + } + + if (change.Direction == 0) + { + return Strings.Change_None; + } + + var magnitude = formatMagnitude(Math.Abs(difference)); + var words = Loc.F(change.Direction > 0 ? Strings.Change_More : Strings.Change_Less, magnitude); + return words + " (" + Format.ChangePercent(change) + ")"; + } + + /// The CSS class colouring a tone with the theme's palette (app.css). + public static string CssClass(ChangeTone tone) => tone switch + { + ChangeTone.Good => "mv-change-good", + ChangeTone.Bad => "mv-change-bad", + _ => "mv-change-neutral", + }; +} diff --git a/src/App/Analysis/CostChanges.cs b/src/App/Analysis/CostChanges.cs new file mode 100644 index 0000000..62e2d43 --- /dev/null +++ b/src/App/Analysis/CostChanges.cs @@ -0,0 +1,56 @@ +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Infrastructure.Dashboard; + +namespace MeterVault.App.Analysis; + +/// +/// How every page states a cost change (D-07, brief §10 Phase 4 exit): one rule — , +/// the totals when both periods are complete, else the paired buckets both sides have complete, else not comparable — +/// and one wording, so the Overview, an energy type, the Analysis page and a meter never disagree about the same scope and +/// period. +/// +public static class CostChanges +{ + /// The change for a card: null without a comparison; unavailable ("No comparison") when not comparable. + public static Change? ForCard(CostChange change) + { + ArgumentNullException.ThrowIfNull(change); + + return change.Basis == CostChangeBasis.NoComparison ? null : change.Change; + } + + /// The change for a table's total row; null unless one is stated. + public static Change? ForTotalRow(CostChange change) + { + ArgumentNullException.ThrowIfNull(change); + + return change.Change.IsAvailable ? change.Change : null; + } + + /// Whether a rise is good news: neutral when either amount is a credit. + public static ChangePolarity Polarity(CostChange change) + { + ArgumentNullException.ThrowIfNull(change); + + return ChangePolarities.ForCost(change.Current, change.Previous); + } + + /// + /// The caption of a change: what it is compared with, and — when only part of the period could be matched — that it + /// is ("Same period last year · over the part both periods cover"). + /// + public static string? Caption(AnalysisQuery query, CostChange change) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(change); + + if (change.Basis == CostChangeBasis.NoComparison) + { + return null; + } + + var compared = query.Comparison.Display(); + return change.IsPartial ? compared + " · " + Strings.Overview_MatchedOnly : compared; + } +} diff --git a/src/App/Analysis/CostComparisonRequest.cs b/src/App/Analysis/CostComparisonRequest.cs new file mode 100644 index 0000000..33ffb8e --- /dev/null +++ b/src/App/Analysis/CostComparisonRequest.cs @@ -0,0 +1,19 @@ +using MeterVault.Core.Analysis; +using MeterVault.Infrastructure.Costing; + +namespace MeterVault.App.Analysis; + +/// +/// How a cost figure is compared (D-06), from : the comparison as resolved, +/// the cost request that prices it in the paired buckets, and the pairs themselves. +/// +/// The comparison period, or why there is none (a code the UI words). +/// The request to price the comparison with; null when the comparison does not apply. +/// Each current bucket with its image, by index (A-10); empty when the comparison does not apply. +public sealed record CostComparisonRequest( + ComparisonResolution Resolution, + CostAnalysisRequest? Request, + IReadOnlyList Pairs) +{ + public bool IsApplicable => Request is not null; +} diff --git a/src/App/Analysis/FigureText.cs b/src/App/Analysis/FigureText.cs new file mode 100644 index 0000000..cb4c83a --- /dev/null +++ b/src/App/Analysis/FigureText.cs @@ -0,0 +1,131 @@ +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; + +namespace MeterVault.App.Analysis; + +/// +/// How one figure reads beside its number (brief §4.3, D-14): whether a number can be shown at all, whether it is +/// qualified — partial, estimated, an opening balance, only partly priced — and its status, detail and provenance in the +/// reader's words. Charts mark a qualified figure, tables and tooltips word it, so its meaning never rests on colour. +/// +/// A number can be shown (a partial total counts; a missing or invalid bucket does not). +/// +/// The figure is complete: an available bucket, or a fully priced cost over available quantities. Only complete figures +/// are compared bucket by bucket (D-07); estimated provenance does not make a figure incomplete. +/// +/// The figure is not a plain complete measured value: incomplete, estimated or an opening balance. +/// The status in words ("Complete", "Partial", "Not priced (no tariff)"). +/// Why, in words, or null. +/// Where the value comes from, in words ("Measured, Estimated"); empty when there is none. +public sealed record FigureStatus(bool IsKnown, bool IsComplete, bool IsQualified, string Status, string? Detail, string Provenance) +{ + /// Status and provenance in one line: "Partial · Measured". + public string Summary => string.IsNullOrEmpty(Provenance) ? Status : Status + " · " + Provenance; + + /// The summary with its detail: "Partial · Measured — Data covers only part of this period". + public string Full => Detail is null ? Summary : Summary + " — " + Detail; +} + +/// Words a or a (). +public static class FigureText +{ + /// Provenance that qualifies a value even when its bucket is complete. + private const Provenance QualifyingProvenance = Provenance.Estimated | Provenance.OpeningBalance; + + /// + /// A quantity bucket: its status and issue (), the issue's detail, and — for a value derived + /// from a dependency — the meter that caused it (the last id of ). + /// + /// The value. + /// Names a meter id for the dependency detail; "#id" without it. + public static FigureStatus Of(BucketValue value, Func? meterName = null) + { + ArgumentNullException.ThrowIfNull(value); + + var known = value.Value is { } number && double.IsFinite(number); + var complete = value.Status == BucketStatus.Available; + var qualified = !complete || (value.Provenance & QualifyingProvenance) != 0; + return new FigureStatus(known, complete, qualified, value.Status.Display(), DetailOf(value, meterName), value.Provenance.Display()); + } + + /// + /// A cost figure: its price coverage (), and as detail the availability of the + /// quantities behind it, components left unpriced and unchecked tariff units. + /// + public static FigureStatus Of(CostAmount amount) + { + ArgumentNullException.ThrowIfNull(amount); + + // Nothing to bill and nothing missing (no line, no charge, no manual cost in the bucket): the engine keeps the + // figure unknown, so it reads "No data" — never "Priced" beside "—", and never complete (brief §4.3). + if (IsNothingBooked(amount)) + { + return new FigureStatus(false, false, true, BucketStatus.Missing.Display(), null, string.Empty); + } + + var known = amount.Cost is { } cost && double.IsFinite(cost); + var complete = amount.Status == CostStatus.Priced && amount.Availability == BucketStatus.Available; + var qualified = !complete || amount.Unverified; + + // Prices cover the bucket but its quantity is unknown (no data, pending, unresolved): the cost is unknown + // because of the quantity, so that is its status — "Priced" beside "—" would read as a priced figure. + var unknownQuantity = !known && amount.Status == CostStatus.Priced && amount.Availability != BucketStatus.Available; + + var details = new List(3); + if (amount.Availability != BucketStatus.Available && !unknownQuantity) + { + details.Add(Loc.F(Strings.Figure_QuantityStatus, amount.Availability.Display())); + } + + if (amount.IncludesNotPriced && amount.Status is CostStatus.Priced or CostStatus.Partial) + { + details.Add(Strings.Figure_SomeNotPriced); + } + + if (amount.Unverified) + { + details.Add(Strings.Figure_UnverifiedUnit); + } + + var status = unknownQuantity ? amount.Availability.Display() : amount.Status.Display(); + return new FigureStatus( + known, complete, qualified, status, details.Count > 0 ? string.Join("; ", details) : null, string.Empty); + } + + /// + /// True for a cost figure with nothing booked in it: no value, nothing unpriced, no quantity unavailable — the empty + /// figure of a bucket with no line, charge or manual cost (). It is unknown, not a + /// priced zero; tables, charts and the CSV export word it as "No data" alike. + /// + public static bool IsNothingBooked(CostAmount amount) + { + ArgumentNullException.ThrowIfNull(amount); + + return amount.Cost is null && amount.Status == CostStatus.Priced && amount.Availability == BucketStatus.Available + && amount.MissingPrices.Count == 0; + } + + private static string? DetailOf(BucketValue value, Func? meterName) + { + if (value.Issue == ValueIssue.None) + { + return null; + } + + var extras = new List(2); + if (!string.IsNullOrWhiteSpace(value.IssueDetail)) + { + extras.Add(value.IssueDetail.Trim()); + } + + if (value.DependencyPath is { Count: > 1 } path) + { + var culprit = path[^1]; + extras.Add(meterName?.Invoke(culprit) is { Length: > 0 } name ? name : "#" + culprit.ToString(System.Globalization.CultureInfo.CurrentCulture)); + } + + var issue = value.Issue.Display(); + return extras.Count == 0 ? issue : issue + " (" + string.Join(", ", extras) + ")"; + } +} diff --git a/src/App/Analysis/FormulaText.cs b/src/App/Analysis/FormulaText.cs new file mode 100644 index 0000000..a4525c7 --- /dev/null +++ b/src/App/Analysis/FormulaText.cs @@ -0,0 +1,112 @@ +using System.Globalization; +using System.Text; + +namespace MeterVault.App.Analysis; + +/// A piece of a virtual meter's formula: plain text, or a meter reference (m12) with its id. +public sealed record FormulaSegment(string Text, int? MeterId) +{ + public bool IsMeter => MeterId is not null; +} + +/// +/// A virtual meter's formula for display (brief §5.1): split into text and meter references so each m<id> +/// token can stand beside its meter's friendly name. The scan mirrors the formula lexer: an identifier is a letter or +/// underscore followed by letters, digits or underscores, a reference is exactly m and digits, and a number run +/// (digits and dots) is skipped whole, so the "3" of "1.3" is never read as part of a name. +/// +public static class FormulaText +{ + /// Splits an expression into segments; empty for null or blank. + public static IReadOnlyList Split(string? expression) + { + if (string.IsNullOrWhiteSpace(expression)) + { + return []; + } + + var segments = new List(); + var text = new StringBuilder(); + var pos = 0; + while (pos < expression.Length) + { + var c = expression[pos]; + if (char.IsLetter(c) || c == '_') + { + var start = pos; + while (pos < expression.Length && (char.IsLetterOrDigit(expression[pos]) || expression[pos] == '_')) + { + pos++; + } + + var token = expression[start..pos]; + if (TryParseReference(token, out var id)) + { + Flush(segments, text); + segments.Add(new FormulaSegment(token, id)); + } + else + { + text.Append(token); + } + } + else if (char.IsAsciiDigit(c) || c == '.') + { + var start = pos; + while (pos < expression.Length && (char.IsAsciiDigit(expression[pos]) || expression[pos] == '.')) + { + pos++; + } + + text.Append(expression, start, pos - start); + } + else + { + text.Append(c); + pos++; + } + } + + Flush(segments, text); + return segments; + } + + /// + /// The expression with each reference followed by its meter's name: m5 (Solar 1) + m6 (Solar 2). A reference + /// nobody named stays as it is. + /// + public static string Annotate(string? expression, Func name) + { + ArgumentNullException.ThrowIfNull(name); + + var builder = new StringBuilder(); + foreach (var segment in Split(expression)) + { + builder.Append(segment.Text); + if (segment.MeterId is { } id && name(id) is { Length: > 0 } meter) + { + builder.Append(" (").Append(meter).Append(')'); + } + } + + return builder.ToString(); + } + + private static bool TryParseReference(string token, out int id) + { + id = 0; + return token.Length >= 2 + && token[0] == 'm' + && token.AsSpan(1).IndexOfAnyExceptInRange('0', '9') < 0 + && int.TryParse(token.AsSpan(1), NumberStyles.None, CultureInfo.InvariantCulture, out id); + } + + private static void Flush(List segments, StringBuilder text) + { + if (text.Length > 0) + { + segments.Add(new FormulaSegment(text.ToString(), null)); + text.Clear(); + } + } +} diff --git a/src/App/Analysis/LoadSequencer.cs b/src/App/Analysis/LoadSequencer.cs new file mode 100644 index 0000000..43fe7cc --- /dev/null +++ b/src/App/Analysis/LoadSequencer.cs @@ -0,0 +1,218 @@ +namespace MeterVault.App.Analysis; + +/// One requested load: its generation and the token that cancels it when a newer load is requested. +public readonly record struct LoadTicket(long Generation, CancellationToken Token); + +/// +/// Makes sure only the latest requested load is committed (brief §8, A13): cancels the load before it +/// and hands out a ticket; a load commits its result only while holds for its ticket. A delayed +/// first request can then never overwrite the scope or range the user selected after it — the old +/// if (_loading) return; guard dropped the newer request instead. +/// +/// +/// The page pattern (one sequencer per independently loading panel): +/// +/// private readonly LoadSequencer _loads = new(); +/// private readonly LoadState<AnalysisResult> _result = new(); +/// +/// protected override async Task OnParametersSetAsync() +/// { +/// var query = AnalysisQuery.Parse(Nav.Uri, Defaults); +/// if (query == _query) return; // an action drop or a tab change is not a new analysis (D-46) +/// _query = query; +/// await _loads.RunAsync(_result, async token => +/// { +/// var period = await Periods.ResolveAsync(query, Clock.Now, token); +/// return await Reader.ReadAsync(query.ToAnalysisRequest(period)!, token); +/// }, Logger); +/// } +/// +/// public void Dispose() => _loads.Dispose(); +/// +/// +/// Render from : → skeleton/progress; +/// with → the previous result, dimmed, with a +/// thin progress bar (stale but visible); → a panel-level error with Retry (and, when a +/// value is kept, the note that it is from before); otherwise the value. Everything a panel shows — title, chart, table — +/// comes from the one committed value, so a new type's title never sits above the previous type's chart. +/// +/// +/// Initial loads stay in OnInitialized/OnParametersSet (D-46): the render tests read prerendered data. +/// +/// +public sealed class LoadSequencer : IDisposable +{ + private readonly object _gate = new(); + private CancellationTokenSource? _current; + private long _generation; + private bool _disposed; + + /// The generation of the latest ticket; 0 before the first. + public long Generation + { + get + { + lock (_gate) + { + return _generation; + } + } + } + + /// Starts a new load: cancels the previous one and returns the new ticket. + /// The sequencer (its component) is disposed. + public LoadTicket Next() + { + CancellationTokenSource? previous; + LoadTicket ticket; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + previous = _current; + _current = new CancellationTokenSource(); + _generation++; + ticket = new LoadTicket(_generation, _current.Token); + } + + // Cancelled before it is disposed, so the token the superseded load holds stays cancelled (and usable). + previous?.Cancel(); + previous?.Dispose(); + return ticket; + } + + /// True while is the latest load and has not been cancelled: only then may it commit. + public bool IsCurrent(LoadTicket ticket) + { + lock (_gate) + { + return !_disposed && ticket.Generation == _generation && !ticket.Token.IsCancellationRequested; + } + } + + /// + /// Runs one load through : marks it loading, awaits with the ticket's + /// token, and commits the value or the error — only if no newer load was requested meanwhile. A superseded or + /// cancelled load changes nothing. Errors are logged and kept in the state for a panel-level Retry; they never + /// escape to the circuit. + /// + /// True when this load committed (a value or an error), false when it was superseded. + public async Task RunAsync(LoadState state, Func> load, ILogger? logger = null) + where T : class + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(load); + + var ticket = Next(); + state.Begin(ticket.Generation); + try + { + var value = await load(ticket.Token); + if (!IsCurrent(ticket)) + { + return false; + } + + state.Commit(value); + return true; + } + catch (OperationCanceledException) when (ticket.Token.IsCancellationRequested) + { + return false; + } + + // A panel shows its own error with Retry: nothing a reader throws may end the circuit. + catch (Exception ex) + { + if (!IsCurrent(ticket)) + { + return false; + } + + logger?.LogError(ex, "Loading {Panel} failed", typeof(T).Name); + state.Fail(ex); + return true; + } + } + + /// Cancels the load in flight; no ticket is current afterwards. + public void Dispose() + { + CancellationTokenSource? current; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + current = _current; + _current = null; + } + + current?.Cancel(); + current?.Dispose(); + } +} + +/// +/// What a panel shows while it loads (brief §8): the last committed value, whether a load is running, and the error of +/// the last load. The value is kept through a refresh and a failure, so a panel stays readable ("stale but visible") +/// instead of blanking. +/// +public sealed class LoadState + where T : class +{ + /// The last committed value; null before the first load finished. + public T? Value { get; private set; } + + /// True while a load is running. + public bool IsLoading { get; private set; } + + /// The error of the last committed load; null after a success. + public Exception? Error { get; private set; } + + /// The generation of the running or last committed load. + public long Generation { get; private set; } + + /// Nothing to show yet: the first load is running. + public bool IsInitialLoad => IsLoading && Value is null; + + /// A value is shown while a newer one loads. + public bool IsRefreshing => IsLoading && Value is not null; + + /// The value shown is not the answer to the current request: a newer load is running, or it failed. + public bool IsStale => Value is not null && (IsLoading || Error is not null); + + /// A load is starting. + public void Begin(long generation) + { + Generation = generation; + IsLoading = true; + } + + /// The load finished with . + public void Commit(T value) + { + Value = value; + Error = null; + IsLoading = false; + } + + /// The load failed; the previous value, if any, stays visible. + public void Fail(Exception error) + { + ArgumentNullException.ThrowIfNull(error); + + Error = error; + IsLoading = false; + } + + /// Forgets the value (e.g. when the page moved to a different entity whose old data must not show). + public void Clear() + { + Value = null; + Error = null; + } +} diff --git a/src/App/Analysis/QueryScope.cs b/src/App/Analysis/QueryScope.cs new file mode 100644 index 0000000..463b27e --- /dev/null +++ b/src/App/Analysis/QueryScope.cs @@ -0,0 +1,186 @@ +using System.Globalization; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; + +namespace MeterVault.App.Analysis; + +/// What an analysis URL is about (D-47 scope=portfolio|type|category|meter|meters). +public enum QueryScopeKind +{ + /// Everything: every energy type's totals and the whole bill. + Portfolio, + + /// One energy type (scope=type&id=). + EnergyType, + + /// One cost category (scope=category&id=); analysed by cost. + Category, + + /// One meter, physical or virtual (scope=meter&id=). + Meter, + + /// An explicit selection of meters side by side (scope=meters&ids=3,5, at most ). + Meters, +} + +/// +/// The scope of an : a kind and the id(s) it names. Immutable, compared by value, and mapped +/// in one place onto the quantity reader's and the cost reader's . +/// +public sealed class QueryScope : IEquatable +{ + private static readonly (QueryScopeKind Value, string Token)[] Tokens = + [ + (QueryScopeKind.Portfolio, "portfolio"), + (QueryScopeKind.EnergyType, "type"), + (QueryScopeKind.Category, "category"), + (QueryScopeKind.Meter, "meter"), + (QueryScopeKind.Meters, "meters"), + ]; + + private QueryScope(QueryScopeKind kind, int? id, IReadOnlyList meterIds) + { + Kind = kind; + Id = id; + MeterIds = meterIds; + } + + /// Every energy type and the whole bill. + public static QueryScope Portfolio { get; } = new(QueryScopeKind.Portfolio, null, []); + + public QueryScopeKind Kind { get; } + + /// The energy type, category or meter id; null for the portfolio and a meter selection. + public int? Id { get; } + + /// The meters of a selection (distinct, in the order given); the one meter of a meter scope; empty otherwise. + public IReadOnlyList MeterIds { get; } + + /// The URL token of . + public string Token => TokenOf(Kind); + + public static QueryScope ForEnergyType(int energyTypeId) => new(QueryScopeKind.EnergyType, Positive(energyTypeId, nameof(energyTypeId)), []); + + public static QueryScope ForCategory(int categoryId) => new(QueryScopeKind.Category, Positive(categoryId, nameof(categoryId)), []); + + public static QueryScope ForMeter(int meterId) + { + Positive(meterId, nameof(meterId)); + return new QueryScope(QueryScopeKind.Meter, meterId, [meterId]); + } + + /// + /// An explicit selection. The ids are made distinct (first occurrence wins). More than + /// are kept as given; the reader refuses them (), + /// and caps them with a notice. + /// + /// No id, or an id that is not positive. + public static QueryScope ForMeters(IEnumerable meterIds) + { + ArgumentNullException.ThrowIfNull(meterIds); + + List ids = [.. meterIds.Distinct()]; + if (ids.Count == 0) + { + throw new ArgumentException("A meter selection needs at least one meter.", nameof(meterIds)); + } + + if (ids.Any(id => id <= 0)) + { + throw new ArgumentException("Meter ids are positive.", nameof(meterIds)); + } + + return new QueryScope(QueryScopeKind.Meters, null, ids); + } + + /// The URL token of a scope kind. + public static string TokenOf(QueryScopeKind kind) + { + foreach (var (value, token) in Tokens) + { + if (value == kind) + { + return token; + } + } + + throw new ArgumentOutOfRangeException(nameof(kind), kind, "No URL token for this scope."); + } + + /// Parses a scope token (portfolio, type, …), ignoring case and surrounding blanks. + public static bool TryParseKind(string? token, out QueryScopeKind kind) + { + kind = default; + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + var text = token.Trim(); + foreach (var (value, name) in Tokens) + { + if (string.Equals(name, text, StringComparison.OrdinalIgnoreCase)) + { + kind = value; + return true; + } + } + + return false; + } + + /// + /// The quantity reader's scope: the portfolio, an energy type, a meter or a selection. Null for a category, which + /// the quantity reader does not know — a category is analysed by cost (brief §7.4). + /// + public AnalysisScope? ToAnalysisScope() => Kind switch + { + QueryScopeKind.Portfolio => AnalysisScope.Portfolio, + QueryScopeKind.EnergyType => AnalysisScope.ForEnergyType(Id!.Value), + QueryScopeKind.Meter => AnalysisScope.ForMeter(Id!.Value), + QueryScopeKind.Meters => AnalysisScope.ForMeters(MeterIds), + _ => null, + }; + + /// + /// The cost reader's scopes: one for the portfolio, a type, a meter or a category, and one per meter for a selection + /// (each meter is priced by its own rule, never as a sum, D-39). + /// + public IReadOnlyList ToCostScopes() => Kind switch + { + QueryScopeKind.Portfolio => [CostScope.Portfolio], + QueryScopeKind.EnergyType => [CostScope.ForEnergyType(Id!.Value)], + QueryScopeKind.Category => [CostScope.ForCategory(Id!.Value)], + QueryScopeKind.Meter => [CostScope.ForMeter(Id!.Value)], + _ => [.. MeterIds.Select(CostScope.ForMeter)], + }; + + public bool Equals(QueryScope? other) => + other is not null && Kind == other.Kind && Id == other.Id && MeterIds.SequenceEqual(other.MeterIds); + + public override bool Equals(object? obj) => Equals(obj as QueryScope); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Kind); + hash.Add(Id); + foreach (var id in MeterIds) + { + hash.Add(id); + } + + return hash.ToHashCode(); + } + + /// portfolio, type:3, meter:12, meters:3,5 — for logs and keys. + public override string ToString() => Kind switch + { + QueryScopeKind.Portfolio => Token, + QueryScopeKind.Meters => Token + ":" + string.Join(',', MeterIds.Select(id => id.ToString(CultureInfo.InvariantCulture))), + _ => Token + ":" + Id!.Value.ToString(CultureInfo.InvariantCulture), + }; + + private static int Positive(int id, string paramName) => + id > 0 ? id : throw new ArgumentOutOfRangeException(paramName, id, "Ids are positive."); +} diff --git a/src/App/AnalysisLinks.cs b/src/App/AnalysisLinks.cs new file mode 100644 index 0000000..77cc797 --- /dev/null +++ b/src/App/AnalysisLinks.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using MeterVault.App.Analysis; + +namespace MeterVault.App; + +/// +/// Addresses of the analysis pages — the Overview, an energy type, the Analysis page (/trends), Solar, +/// Consumables — and of the CSV export, each carrying the period of an so a drill-down or a +/// Back link never loses the dates (brief §3.1, D-47). Meter pages are '. +/// +/// +/// Keys a route already had come first, the analysis keys after them (D-47). A key equal to the target page's default +/// is left out (D-02), so a link written from the Overview's month to date says period=mtd to a history page but +/// nothing to the Overview. +/// +public static class AnalysisLinks +{ + /// The Overview tab of an energy type page: quantities, cost, coverage, changes (brief §7.3). + public const string EnergyTabOverview = "overview"; + + /// The History tab: the shared chart and table. + public const string EnergyTabHistory = "history"; + + /// The Flow tab: the Sankey and its table. + public const string EnergyTabFlow = "flow"; + + /// The Meters tab: the type's meters with their period values. + public const string EnergyTabMeters = "meters"; + + /// The route of the CSV export (D-55). + public const string ExportPath = "/export/analysis.csv"; + + /// The energy type page's tabs, in order; the first is the default. + public static IReadOnlyList EnergyTabs { get; } = [EnergyTabOverview, EnergyTabHistory, EnergyTabFlow, EnergyTabMeters]; + + /// The energy type tab a requested key opens: the key itself (any case), or Overview for anything else. + public static string ResolveEnergyTab(string? tab) + { + var key = tab?.Trim().ToLowerInvariant(); + return key is not null && EnergyTabs.Contains(key, StringComparer.Ordinal) ? key : EnergyTabOverview; + } + + /// The panel index of a requested energy type tab. + public static int EnergyTabIndex(string? tab) + { + var key = ResolveEnergyTab(tab); + for (var i = 0; i < EnergyTabs.Count; i++) + { + if (string.Equals(EnergyTabs[i], key, StringComparison.Ordinal)) + { + return i; + } + } + + return 0; + } + + /// The Overview (/), carrying the period — the target of breadcrumbs and Back. + public static string Overview(AnalysisQuery? query = null) => Carry("/", query, AnalysisDefaults.Overview); + + /// + /// An energy type page (/energy/{id}), optionally on a tab (; the Overview tab is not + /// written), carrying the period. + /// + public static string EnergyType(int energyTypeId, string? tab = null, AnalysisQuery? query = null) + { + var url = "/energy/" + energyTypeId.ToString(CultureInfo.InvariantCulture); + if (!string.IsNullOrWhiteSpace(tab) && ResolveEnergyTab(tab) is var key && key != EnergyTabOverview) + { + url += "?tab=" + key; + } + + return Carry(url, query, AnalysisDefaults.History); + } + + /// + /// The Analysis page (/trends) for a scope and metric, carrying the rest of : + /// /trends?scope=type&id=3&metric=cost&period=ytd. A null keeps the + /// query's. + /// + public static string Analysis(QueryScope scope, AnalysisMetric? metric = null, AnalysisQuery? query = null) + { + ArgumentNullException.ThrowIfNull(scope); + + var target = (query ?? AnalysisQuery.Default(AnalysisDefaults.History)).WithScope(scope); + if (metric is not null) + { + target = target.WithMetric(metric); + } + + return target.AppendTo("/trends", AnalysisDefaults.History, AnalysisQueryParts.All); + } + + /// Solar (/solar), carrying the period. + public static string Solar(AnalysisQuery? query = null) => Carry("/solar", query, AnalysisDefaults.History); + + /// Tanks & consumables (/consumables), carrying the period. + public static string Consumables(AnalysisQuery? query = null) => Carry("/consumables", query, AnalysisDefaults.History); + + /// + /// The CSV export of what shows (D-55): every key that differs from the export's defaults, + /// scope included — the export has no route to imply one. + /// + public static string Export(AnalysisQuery query) + { + ArgumentNullException.ThrowIfNull(query); + + return query.AppendTo(ExportPath, AnalysisDefaults.Export, AnalysisQueryParts.All); + } + + private static string Carry(string url, AnalysisQuery? query, AnalysisDefaults target) => + query is null ? url : query.AppendTo(url, target); +} diff --git a/src/App/AnalysisPage/AnalysisPageLoader.cs b/src/App/AnalysisPage/AnalysisPageLoader.cs new file mode 100644 index 0000000..7a8bbed --- /dev/null +++ b/src/App/AnalysisPage/AnalysisPageLoader.cs @@ -0,0 +1,309 @@ +using MeterVault.App.Analysis; +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; +using MeterVault.Infrastructure.Dashboard; + +namespace MeterVault.App.AnalysisPage; + +/// +/// Reads what the Analysis page shows for one address (brief §7.4): quantities through the shared analysis reader and +/// costs through the cost reader — the same calls the meter and energy type pages, the Overview and the CSV export make +/// — and turns them into chart and table inputs once, inside the load. No figure is computed here: the page never adds +/// up series, never prices anything, never fills a gap. +/// +/// +/// +/// Quantities. The portfolio and an energy type show the per-type measures of the metric (consumption: total use +/// and grid import, side by side, never added — D-22); a meter, a comparison and a category's meters each their own +/// series. One meter also shows its cost by its rule in the same buckets. +/// +/// +/// Costs. One cost series per cost scope: the whole bill with manual costs and standing charges (the figure the +/// Overview shows for the same range), a type's bill, a category (D-42) or a meter by its rule — each priced month by month, +/// so the bucket size never changes a total (D-36). A comparison is priced in the images of the current buckets (A-10). +/// The priced meters' quantities are read once more for their resolution, which decides how far a bucket can be drilled +/// into (D-51) — a monthly import has no finer detail to open. +/// +/// +public sealed class AnalysisPageLoader(AnalysisReader reader, CostReader costs, AnalysisPeriods periods) +{ + /// More base series than this and the comparison stays in the table: overlays would crowd the chart. + public const int MaxOverlaidSeries = 3; + + /// Reads the page for (the address as parsed) and its . + /// The address's analysis state. + /// The address read against the options (). + /// The names of types and meters. + /// The instant captured once for this load (D-01). + /// Cancels a superseded load. + public async Task LoadAsync( + AnalysisQuery query, AnalysisSelection selection, AnalysisPageOptions options, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(selection); + ArgumentNullException.ThrowIfNull(options); + + var read = selection.ReadQuery(query); + var kind = selection.IsCost ? AnalysisPageViewKind.Cost : AnalysisPageViewKind.Quantity; + if (selection.Refusal != AnalysisPageRefusal.None) + { + // Nothing to read; the dates still show in the toolbar. + return new AnalysisPageView(read, selection, read.Resolve(now, periods.Zone), kind); + } + + var period = await periods.ResolveAsync(read, now, cancellationToken).ConfigureAwait(false); + return kind == AnalysisPageViewKind.Cost + ? await CostAsync(read, selection, options, period, cancellationToken).ConfigureAwait(false) + : await QuantityAsync(read, selection, options, period, cancellationToken).ConfigureAwait(false); + } + + private async Task QuantityAsync( + AnalysisQuery read, AnalysisSelection selection, AnalysisPageOptions options, ResolvedPeriod period, CancellationToken cancellationToken) + { + var view = new AnalysisPageView(read, selection, period, AnalysisPageViewKind.Quantity); + var result = await reader.ReadAsync(read.ToAnalysisRequest(period)!, cancellationToken).ConfigureAwait(false); + if (result.Refusal != AnalysisRefusal.None) + { + return view with { Plan = result.Plan, ReaderRefusal = result.Refusal }; + } + + List series; + Dictionary names = new(StringComparer.Ordinal); + AvailableRange? availability; + if (selection.ShowsMeters) + { + series = [.. selection.SeriesMeterIds.Select(result.SeriesFor).OfType()]; + foreach (var item in series) + { + names[item.Key.Id] = item.Name; + } + + availability = AvailableRange.Union(series.Select(s => s.Availability), reader.Zone); + } + else + { + var measures = AnalysisMetrics.MeasuresOf(selection.Metric ?? AnalysisMetric.Consumption); + var typeOrder = options.Types.Select((t, i) => (t.Id, i)).ToDictionary(p => p.Id, p => p.i); + series = + [ + .. result.Measures + .Where(m => m.Key.Measure is { } measure && measures.Contains(measure)) + .Where(m => selection.EnergyTypeId is not { } typeId || m.EnergyTypeId == typeId) + .OrderBy(m => m.EnergyTypeId is { } id ? typeOrder.GetValueOrDefault(id, int.MaxValue) : int.MaxValue) + .ThenBy(m => m.Key.Measure), + ]; + foreach (var item in series) + { + names[item.Key.Id] = MeasureName(item, series, selection.Scope.Kind == QueryScopeKind.Portfolio, options); + } + + availability = result.Availability.Quantity; + } + + CostAnalysis? meterCost = null; + var meterCostChange = CostChange.NoComparison; + if (selection.Scope.Kind == QueryScopeKind.Meter && options.Meter(selection.Scope.Id) is { IsCostable: true } meter) + { + var priced = await costs.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter.Id), period) { Plan = result.Plan }, cancellationToken) + .ConfigureAwait(false); + meterCost = priced.Refusal == CostRefusal.None && priced.Meter is not { Rule: MeterCostRule.None } ? priced : null; + + // Its change, as the meter's own page states it (D-07): priced in the paired buckets of the comparison. + if (meterCost is not null && read.Comparison.Kind != ComparisonKind.None + && read.ToCostComparison(meterCost.Request, meterCost.Plan) is { Request: { } comparisonRequest } costComparison) + { + var previous = await costs.ReadAsync(comparisonRequest, cancellationToken).ConfigureAwait(false); + meterCostChange = OverviewComparison.Between(meterCost.Buckets, meterCost.Total, previous.Buckets, previous.Total, costComparison.Pairs); + } + } + + // A derived value names the meter it misses (a source without data, a loop) by its catalog name, as the attention + // list beside it does — never by its id. + string? MeterNameOf(int id) => options.Meter(id)?.Name; + + var overlays = series.Count <= MaxOverlaidSeries; + List chart = []; + List table = []; + foreach (var item in series) + { + var name = names[item.Key.Id]; + chart.Add(AnalysisChartSeries.ForSeries(item, name, meterName: MeterNameOf)); + if (overlays && AnalysisChartSeries.ComparisonOf(item, AnalysisChartSeries.ComparisonName(name, read.Comparison), meterName: MeterNameOf) is { } overlay) + { + chart.Add(overlay); + } + + var row = AnalysisTableSeries.ForSeries(item, name, MeterNameOf); + table.Add(meterCost is not null ? row.WithCosts(meterCost.Buckets, meterCost.Total, meterCost.Currency) : row); + } + + List problems = [.. result.Problems]; + if (meterCost is not null) + { + problems.AddRange(meterCost.QuantityProblems); + } + + return view with + { + Plan = result.Plan, + Quantities = result, + Series = series, + SeriesNames = names, + MeterCost = meterCost, + MeterCostChange = meterCostChange, + Chart = chart, + Table = table, + Pairs = result.Comparison is { IsApplicable: true } comparison ? comparison.Buckets : null, + Comparison = result.Comparison?.Resolution, + Matched = series.Count == 1 ? series[0].Comparison?.Matched : null, + Resolution = Coarsest(series.Select(s => s.Resolution)), + Availability = availability, + Problems = problems, + CostAttention = meterCost?.Attention ?? [], + }; + } + + private async Task CostAsync( + AnalysisQuery read, AnalysisSelection selection, AnalysisPageOptions options, ResolvedPeriod period, CancellationToken cancellationToken) + { + var view = new AnalysisPageView(read, selection, period, AnalysisPageViewKind.Cost); + + // A comparison prices only the meters that can have a cost (D-34); the others are named on the page. + var requests = read.ToCostRequests(period) + .Where(r => r.Scope.Kind != CostScopeKind.Meter || selection.Scope.Kind == QueryScopeKind.Meter || selection.SeriesMeterIds.Contains(r.Scope.Id!.Value)) + .ToList(); + + BucketPlan? plan = null; + ComparisonResolution? resolution = null; + IReadOnlyList? pairs = null; + var series = new List(requests.Count); + foreach (var request in requests) + { + var current = await costs.ReadAsync(plan is null ? request : request with { Plan = plan }, cancellationToken).ConfigureAwait(false); + if (current.Refusal == CostRefusal.TooManyPoints) + { + return view with { Plan = current.Plan, ReaderRefusal = AnalysisRefusal.TooManyPoints }; + } + + if (current.Refusal == CostRefusal.UnknownScope) + { + continue; + } + + plan ??= current.Plan; + + CostAnalysis? previous = null; + IReadOnlyList seriesPairs = []; + if (read.Comparison.Kind != ComparisonKind.None) + { + var comparison = read.ToCostComparison(request with { Plan = current.Plan }, current.Plan); + resolution ??= comparison.Resolution; + if (comparison.Request is { } comparisonRequest) + { + pairs ??= comparison.Pairs; + seriesPairs = comparison.Pairs; + previous = await costs.ReadAsync(comparisonRequest, cancellationToken).ConfigureAwait(false); + } + } + + var (key, name) = CostSeriesName(request.Scope, options); + series.Add(new AnalysisCostSeries(key, name, current, previous) { Pairs = seriesPairs }); + } + + plan ??= BucketPlanner.Plan(period, read.Bucket == BucketSize.Auto ? BucketSize.Month : read.Bucket); + + var overlays = series.Count <= MaxOverlaidSeries; + List chart = []; + List table = []; + foreach (var item in series) + { + var currency = item.Current.Currency; + chart.Add(AnalysisChartSeries.ForCost(item.Key, item.Name, currency, item.Current.Buckets)); + if (overlays && item.Comparison is { } previous) + { + chart.Add(AnalysisChartSeries.ComparisonForCost( + item.Key, AnalysisChartSeries.ComparisonName(item.Name, read.Comparison), currency, previous.Buckets)); + } + + var row = AnalysisTableSeries.ForCosts(item.Key, item.Name, currency, item.Current.Buckets, item.Current.Total); + table.Add(item.Comparison is { } compared + ? row.WithComparisonCosts(compared.Buckets, compared.Total, currency) with { TotalChange = CostChanges.ForTotalRow(item.CostChange) } + : row); + } + + // The priced meters' own data decides how fine a bucket can be opened (D-51): a monthly import has no days. A line + // without any tariff adds nothing to the figure, so its data does not hold the drill-down back. + var priced = series + .SelectMany(s => s.Current.Lines + .Where(l => l.Total.Status != Core.Analysis.Costing.CostStatus.NotPriced) + .Select(l => l.MeterId) + .Concat(s.Current.Meter is { } m ? [m.MeterId] : [])) + .Distinct() + .ToList(); + AnalysisResult? quantities = null; + if (priced.Count > 0) + { + quantities = await reader.ReadAsync( + new AnalysisRequest(AnalysisScope.ForMeters(priced), period) { Plan = plan, MaxSeries = int.MaxValue }, cancellationToken) + .ConfigureAwait(false); + } + + return view with + { + Plan = plan, + Quantities = quantities, + Costs = series, + Chart = chart, + Table = table, + Pairs = pairs, + Comparison = resolution ?? (read.Comparison.Kind == ComparisonKind.None ? null : ComparisonResolver.Resolve(period, read.Comparison)), + Resolution = quantities is null ? null : Coarsest(quantities.Series.Select(s => s.Resolution)), + Availability = AvailableRange.Union(series.Select(s => s.Current.Availability.Range), costs.Zone), + Problems = [.. series.SelectMany(s => s.Current.QuantityProblems)], + CostAttention = [.. series.SelectMany(s => s.Current.Attention)], + }; + } + + /// The coarsest of the given resolutions; null when none is known. + private static ResolutionClass? Coarsest(IEnumerable resolutions) + { + ResolutionClass? coarsest = null; + foreach (var resolution in resolutions) + { + if (resolution is { } value && (coarsest is null || value > coarsest)) + { + coarsest = value; + } + } + + return coarsest; + } + + /// + /// A measure's name: its wording ("Total use"), prefixed with its energy type on the portfolio ("Strom · Total use"), + /// and with its unit when the same measure appears in two units. + /// + private static string MeasureName(AnalysisSeries series, IReadOnlyList all, bool withType, AnalysisPageOptions options) + { + var name = AnalysisChartSeries.NameOf(series); + if (withType && series.EnergyTypeId is { } typeId) + { + name = options.TypeName(typeId) + " · " + name; + } + + var twins = all.Count(s => s.EnergyTypeId == series.EnergyTypeId && s.Key.Measure == series.Key.Measure); + return twins > 1 ? name + " (" + series.Unit + ")" : name; + } + + private static (string Key, string Name) CostSeriesName(CostScope scope, AnalysisPageOptions options) => scope.Kind switch + { + CostScopeKind.EnergyType => ("cost:" + scope, options.TypeName(scope.Id!.Value)), + CostScopeKind.Meter => ("cost:" + scope, options.MeterName(scope.Id!.Value)), + CostScopeKind.Category => ("cost:" + scope, options.Category(scope.Id)?.Name ?? scope.ToString()), + _ => ("cost:portfolio", Strings.Analysis_TotalCost), + }; +} diff --git a/src/App/AnalysisPage/AnalysisPageOptions.cs b/src/App/AnalysisPage/AnalysisPageOptions.cs new file mode 100644 index 0000000..4f110c1 --- /dev/null +++ b/src/App/AnalysisPage/AnalysisPageOptions.cs @@ -0,0 +1,194 @@ +using MeterVault.App.Analysis; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.App.AnalysisPage; + +/// +/// One meter the Analysis page can pick (brief §7.4): its name (user data, never translated), its energy type, whether it +/// is calculated, what it measures in which normalized unit (D-20), and whether it can have a cost of its own (D-34, +/// D-39). +/// +/// The meter. +/// Its name. +/// Its energy type. +/// A calculated (virtual) meter. +/// What its values measure. +/// The normalized unit of its values. +/// +/// True when a cost can be formed for it: a physical consumption or export meter (a bill line or a view at its price), or +/// a virtual meter whose cost rule is not none. Generation and runtime are never billed. +/// +/// Retired: it keeps its history (D-24), and the pickers say so. +public sealed record AnalysisPageMeter( + int Id, string Name, int EnergyTypeId, bool IsVirtual, QuantityKind Kind, string Unit, bool IsCostable, bool IsRetired = false) +{ + /// The metric the meter's own series is charted under; null for an indicator, which has none. + public AnalysisMetric? Metric => AnalysisMetrics.MetricOf(Kind); +} + +/// An energy type and the quantity metrics its totals report (D-22: a measure per metric). +/// The energy type. +/// Its display name (user data). +/// The quantity metrics its measures cover, in . +public sealed record AnalysisPageType(int Id, string Name, IReadOnlyList QuantityMetrics); + +/// A cost category and its meters (meter members plus the meters of its energy-type members, D-42). +/// The category. +/// Its name (user data). +/// Its meters, ascending, each once. +public sealed record AnalysisPageCategory(int Id, string Name, IReadOnlyList MeterIds); + +/// +/// What the Analysis page can offer: the energy types with the metrics they support, the cost categories with their +/// meters, and every meter with its quantity — the input of , loaded once per page +/// from the same catalog the reader classifies with, so the choices never promise a measure the reader does not have. +/// +public sealed class AnalysisPageOptions +{ + private readonly Dictionary _types; + private readonly Dictionary _categories; + private readonly Dictionary _meters; + + public AnalysisPageOptions( + IEnumerable types, IEnumerable categories, IEnumerable meters) + { + ArgumentNullException.ThrowIfNull(types); + ArgumentNullException.ThrowIfNull(categories); + ArgumentNullException.ThrowIfNull(meters); + + Types = [.. types]; + Categories = [.. categories]; + + // Grouped by energy type in the types' order, then by name: the order of the pickers. + var typeOrder = Types.Select((t, i) => (t.Id, i)).ToDictionary(p => p.Id, p => p.i); + Meters = + [ + .. meters + .OrderBy(m => typeOrder.GetValueOrDefault(m.EnergyTypeId, int.MaxValue)) + .ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(m => m.Id), + ]; + + _types = Types.ToDictionary(t => t.Id); + _categories = Categories.ToDictionary(c => c.Id); + _meters = Meters.ToDictionary(m => m.Id); + } + + /// The quantity metrics in the order every list offers them. + public static IReadOnlyList QuantityOrder { get; } = + [AnalysisMetric.Consumption, AnalysisMetric.Generation, AnalysisMetric.Export, AnalysisMetric.Runtime, AnalysisMetric.Net]; + + public static AnalysisPageOptions Empty { get; } = new([], [], []); + + /// The energy types, in their stored order. + public IReadOnlyList Types { get; } + + /// The cost categories, in their sort order. + public IReadOnlyList Categories { get; } + + /// Every meter, grouped by energy type, then by name. + public IReadOnlyList Meters { get; } + + public AnalysisPageType? Type(int? id) => id is { } key ? _types.GetValueOrDefault(key) : null; + + public AnalysisPageCategory? Category(int? id) => id is { } key ? _categories.GetValueOrDefault(key) : null; + + public AnalysisPageMeter? Meter(int? id) => id is { } key ? _meters.GetValueOrDefault(key) : null; + + /// The energy type's name; "#id" when it is unknown. + public string TypeName(int id) => Type(id)?.Name ?? "#" + id.ToString(System.Globalization.CultureInfo.CurrentCulture); + + /// The meter's name; "#id" when it is unknown. + public string MeterName(int id) => Meter(id)?.Name ?? "#" + id.ToString(System.Globalization.CultureInfo.CurrentCulture); + + /// Meter names by id, for attention items and contributions. + public IReadOnlyDictionary MeterNames => _meters.ToDictionary(p => p.Key, p => p.Value.Name); + + /// Energy type names by id. + public IReadOnlyDictionary TypeNames => _types.ToDictionary(p => p.Key, p => p.Value.Name); + + /// The quantity metric of a per-type measure (D-22): household use and grid import are both consumption. + public static AnalysisMetric MetricOf(TotalsMeasure measure) => measure switch + { + TotalsMeasure.Use or TotalsMeasure.GridImport => AnalysisMetric.Consumption, + TotalsMeasure.Generation => AnalysisMetric.Generation, + TotalsMeasure.Export => AnalysisMetric.Export, + _ => AnalysisMetric.Runtime, + }; + + /// + /// The options from loaded rows: the reader's catalog (normalized quantities, effective virtual definitions, the + /// totals classification), the energy types and the categories with their members. + /// + public static AnalysisPageOptions Build(AnalysisCatalog catalog, IEnumerable types, IEnumerable categories) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(types); + ArgumentNullException.ThrowIfNull(categories); + + var typeList = types.OrderBy(t => t.Id).ToList(); + var pageTypes = typeList.Select(t => + { + var measures = catalog.Totals.ForType(t.Id).Measures.Where(g => g.MeterIds.Count > 0).Select(g => MetricOf(g.Measure)).ToHashSet(); + return new AnalysisPageType(t.Id, t.DisplayName, [.. QuantityOrder.Where(measures.Contains)]); + }); + + var meters = catalog.Meters.Values.Select(m => new AnalysisPageMeter( + m.Id, + m.Name, + m.EnergyTypeId, + m.IsVirtual, + m.Quantity.Kind, + m.Quantity.Unit, + IsCostable(m), + m.Meter.RetiredAt is not null)); + + var metersByType = catalog.Meters.Values.GroupBy(m => m.EnergyTypeId).ToDictionary(g => g.Key, g => g.Select(m => m.Id).ToList()); + var pageCategories = categories + .OrderBy(c => c.Sort) + .ThenBy(c => c.Name, StringComparer.CurrentCultureIgnoreCase) + .Select(c => new AnalysisPageCategory( + c.Id, + c.Name, + [ + .. c.Members + .SelectMany(member => member.MeterId is { } meterId + ? [meterId] + : member.EnergyTypeId is { } typeId ? metersByType.GetValueOrDefault(typeId) ?? [] : (IEnumerable)[]) + .Where(catalog.Meters.ContainsKey) + .Distinct() + .Order(), + ])); + + return new AnalysisPageOptions(pageTypes, pageCategories, meters); + } + + /// Loads the options: the reader's catalog, the energy types and the categories with their members. + public static async Task LoadAsync( + IDbContextFactory contextFactory, AnalysisReader reader, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(contextFactory); + ArgumentNullException.ThrowIfNull(reader); + + var catalog = await reader.LoadCatalogAsync(cancellationToken).ConfigureAwait(false); + await using var db = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var types = await db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); + var categories = await db.CostCategories.AsNoTracking().Include(c => c.Members).ToListAsync(cancellationToken).ConfigureAwait(false); + return Build(catalog, types, categories); + } + + /// + /// Whether a meter can have a cost of its own: physical consumption and export (D-34), a virtual meter with a cost rule + /// (D-39) — never generation or runtime, which are not billed, and never an indicator. + /// + private static bool IsCostable(AnalysisMeter meter) => meter.IsVirtual + ? meter.CostRule != VirtualCostRule.None && meter.Quantity.Kind is QuantityKind.Consumption or QuantityKind.Net + : meter.Quantity.Kind is QuantityKind.Consumption or QuantityKind.Export; +} diff --git a/src/App/AnalysisPage/AnalysisPageView.cs b/src/App/AnalysisPage/AnalysisPageView.cs new file mode 100644 index 0000000..bf84287 --- /dev/null +++ b/src/App/AnalysisPage/AnalysisPageView.cs @@ -0,0 +1,125 @@ +using MeterVault.App.Analysis; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; +using MeterVault.Infrastructure.Dashboard; + +namespace MeterVault.App.AnalysisPage; + +/// What the Analysis page charts: quantities from the analysis reader, or costs from the cost reader. +public enum AnalysisPageViewKind +{ + Quantity, + Cost, +} + +/// +/// One cost series of the page: a cost scope (the portfolio, a type, a category or a meter), its figure priced by the cost +/// reader, and — when a comparison applies — the same scope priced in the paired buckets (D-06, A-10). +/// +/// A stable key for the chart and table. +/// The series name (user data for a type, category or meter). +/// The priced period. +/// The comparison priced in the images of the current buckets; null when none applies. +public sealed record AnalysisCostSeries(string Key, string Name, CostAnalysis Current, CostAnalysis? Comparison) +{ + /// The comparison's buckets paired with the current ones (A-10); empty without a comparison. + public IReadOnlyList Pairs { get; init; } = []; + + /// + /// The change of the period's cost against the comparison, by the rule every page states it with (D-07, + /// ): the totals when both are complete, else the paired buckets complete on + /// both sides, else not comparable. + /// + public CostChange CostChange => Comparison is { } previous && Pairs.Count > 0 + ? OverviewComparison.Between(Current.Buckets, Current.Total, previous.Buckets, previous.Total, Pairs) + : CostChange.NoComparison; +} + +/// +/// Everything the Analysis page shows for one address, committed at once (brief §8): the selection, the resolved period +/// and buckets, the series with their chart and table inputs (formatted in the request's culture), the comparison, the +/// availability behind the empty states, and the attention items. Nothing here is computed by the page itself — the +/// quantities are the analysis reader's and the costs the cost reader's, so the page agrees with every other view. +/// +public sealed record AnalysisPageView(AnalysisQuery Query, AnalysisSelection Selection, ResolvedPeriod Period, AnalysisPageViewKind Kind) +{ + /// The buckets; null when nothing was read (a refused selection). + public BucketPlan? Plan { get; init; } + + /// The quantity result (the quantity view), or the priced meters' quantities read for their resolution (the cost view). + public AnalysisResult? Quantities { get; init; } + + /// The quantity series shown, in the order charted. + public IReadOnlyList Series { get; init; } = []; + + /// Display names of , by series key. + public IReadOnlyDictionary SeriesNames { get; init; } = new Dictionary(); + + /// The cost series shown (the cost view). + public IReadOnlyList Costs { get; init; } = []; + + /// For one meter's quantity: its cost by its rule, in the same buckets (null when it is not costed). + public CostAnalysis? MeterCost { get; init; } + + /// The change of against the comparison, by the rule every page uses (D-07). + public CostChange MeterCostChange { get; init; } = CostChange.NoComparison; + + public IReadOnlyList Chart { get; init; } = []; + + public IReadOnlyList Table { get; init; } = []; + + /// The comparison buckets paired with the plan's (A-10); null without a comparison. + public IReadOnlyList? Pairs { get; init; } + + /// How the comparison was resolved, or why there is none. + public ComparisonResolution? Comparison { get; init; } + + /// The coverage both periods share, for a single quantity series (D-07). + public MatchedCoverageResult? Matched { get; init; } + + /// The coarsest resolution among the charted data: how far a bucket can be drilled into (D-51). + public ResolutionClass? Resolution { get; init; } + + /// What the view's scope has data for (D-19), for "No data for this period" and "Go to latest data". + public AvailableRange? Availability { get; init; } + + /// The readers refused the request before reading (too many meters or buckets). + public AnalysisRefusal ReaderRefusal { get; init; } + + public IReadOnlyList Problems { get; init; } = []; + + public IReadOnlyList CostAttention { get; init; } = []; + + /// The whole range lies after now (D-04). + public bool NotYetOccurred => Period.HasNotStarted(); + + /// True when nothing was shown for the address: the selection or the readers refused it (an explanation instead). + public bool IsRefused => Selection.Refusal != AnalysisPageRefusal.None || ReaderRefusal != AnalysisRefusal.None; + + /// Every series is being rebuilt (D-16): "analysis being prepared", never "no data". + public bool IsPending => Kind == AnalysisPageViewKind.Quantity + ? Series.Count > 0 && Series.All(s => s.IsPending) + : Costs.Count > 0 && Costs.All(c => c.Current.Total.Availability == BucketStatus.Pending); + + /// + /// Nothing is known in the period: no series, every quantity total missing, or no cost figure with a value, nothing that + /// needs a price and no quantity that is only unresolved. A known zero is data; a missing price is not "no data" (the + /// cost cards say "not priced"), nor is a quantity the buckets cannot resolve (the cards name its state), nor a + /// category whose members price nothing — the attention list says why (A-22), and "nothing recorded yet" would be false. + /// + public bool HasNoData => Kind == AnalysisPageViewKind.Quantity + ? Series.Count == 0 || Series.All(s => s.Total.Status == BucketStatus.Missing) + : Costs.Count == 0 + || (Costs.All(c => c.Current.Total is { Cost: null, Status: Core.Analysis.Costing.CostStatus.Priced, Availability: BucketStatus.Missing or BucketStatus.Available }) + && !CostAttention.Any(a => a.Kind == CostAttentionKind.CategoryPricesNothing)); + + /// The name of a quantity series as the page shows it. + public string NameOf(AnalysisSeries series) + { + ArgumentNullException.ThrowIfNull(series); + + return SeriesNames.TryGetValue(series.Key.Id, out var name) ? name : AnalysisChartSeries.NameOf(series); + } +} diff --git a/src/App/AnalysisPage/AnalysisSelection.cs b/src/App/AnalysisPage/AnalysisSelection.cs new file mode 100644 index 0000000..0649ec8 --- /dev/null +++ b/src/App/AnalysisPage/AnalysisSelection.cs @@ -0,0 +1,334 @@ +using MeterVault.App.Analysis; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.App.AnalysisPage; + +/// Why the Analysis page cannot show what its address asks for, and explains instead (brief §7.4). +public enum AnalysisPageRefusal +{ + None, + + /// The energy type, category or meter does not exist (any more). + UnknownScope, + + /// More meters than can be compared side by side (); never cut silently. + TooManyMeters, + + /// A category asked for a quantity while its meters measure different kinds or units. + CategoryMixed, + + /// A category asked for a quantity has no meters (its costs are manual costs only). + CategoryWithoutMeters, + + /// A category asked for a quantity has more meters than can be charted side by side. + CategoryTooManyMeters, +} + +/// What the page did differently from the address, and says so. +public enum AnalysisPageNoticeKind +{ + /// The metric does not apply to the selection; its natural metric is shown instead. + MetricNotAvailable, + + /// Some selected meters do not exist and were left out. + UnknownMetersLeftOut, +} + +/// A page notice with the metric it is about (for ). +public sealed record AnalysisPageNotice(AnalysisPageNoticeKind Kind, AnalysisMetric? Requested = null, AnalysisMetric? Shown = null); + +/// Meters of one kind and unit, e.g. the consumption meters of a mixed category, in kWh. +public sealed record AnalysisMeterGroup(QuantityKind Kind, string Unit, IReadOnlyList MeterIds) +{ + public AnalysisMetric? Metric => AnalysisMetrics.MetricOf(Kind); +} + +/// +/// The Analysis page's reading of its address against what exists (brief §7.4, D-47): the scope and its name, the metrics +/// the scope supports and the one shown, the meters shown as series, and — when the address asks for something that +/// cannot be shown as one quantity — the reason in . Pure: the page, the CSV link and the tests read +/// the same answer. +/// +/// +/// +/// Metrics. The portfolio and an energy type offer the quantity metrics of their per-type measures (D-22) and the +/// cost; a meter its own quantity and — when it can be costed — its cost; a comparison the metrics of its meters and the +/// cost. A cost category is analysed by cost, and by a quantity only when all its meters measure one kind in one unit. +/// A metric the scope does not support falls back to the scope's natural one with a notice (D-02), except a category +/// quantity, which is explained, never silently turned into a cost. +/// +/// +/// Series. A comparison shows the meters that measure the chosen metric (a meter measures what it measures) and +/// names the others; its cost shows the meters that can have one. A category's quantity is its meters side by side — +/// each from the shared reader, never added up, because members may overlap (D-22). +/// +/// +public sealed record AnalysisSelection +{ + private AnalysisSelection(QueryScope scope, AnalysisMetric? metric) + { + Scope = scope; + Metric = metric; + } + + /// The scope shown (unknown meters of a comparison left out). + public QueryScope Scope { get; private init; } + + /// The metric shown; null for a meter's own quantity when it has no metric (an indicator). + public AnalysisMetric? Metric { get; private init; } + + /// The metrics the scope supports, in the order the selector lists them. + public IReadOnlyList Metrics { get; private init; } = []; + + /// What the scope shows without a metric key. + public AnalysisMetric? NaturalMetric { get; private init; } + + /// The meters shown as series (meter, comparison, category quantity); empty for measure and whole-scope cost views. + public IReadOnlyList SeriesMeterIds { get; private init; } = []; + + /// Selected meters not shown for this metric (another kind, or no cost of their own). + public IReadOnlyList HiddenMeterIds { get; private init; } = []; + + /// For : the category's meters by kind and unit. + public IReadOnlyList Groups { get; private init; } = []; + + public AnalysisPageRefusal Refusal { get; private init; } + + public IReadOnlyList Notices { get; private init; } = []; + + /// The scope's name (a type, category or meter — user data); null for the portfolio and a comparison. + public string? ScopeName { get; private init; } + + /// The energy type the scope belongs to (a type, or a meter's type). + public int? EnergyTypeId { get; private init; } + + /// True when the metric shown is the cost. + public bool IsCost => Metric == AnalysisMetric.Cost; + + /// True when the values shown are meters' own series (a meter, a comparison, a category's meters). + public bool ShowsMeters => !IsCost && Scope.Kind is QueryScopeKind.Meter or QueryScopeKind.Meters or QueryScopeKind.Category; + + /// + /// The query the readers are asked with: the metric shown written out, and for a view of meters their explicit + /// selection — so resolving all (D-19), reading and the CSV export all see the same scope. + /// + public AnalysisQuery ReadQuery(AnalysisQuery query) + { + ArgumentNullException.ThrowIfNull(query); + + var read = query.WithMetric(Metric); + return ShowsMeters && Scope.Kind != QueryScopeKind.Meter && SeriesMeterIds.Count > 0 + ? read.WithScope(QueryScope.ForMeters(SeriesMeterIds)) + : read.WithScope(Scope); + } + + /// + /// as the page shows it: the scope shown and the metric shown, with no metric key + /// when it is the scope's natural one — the state the selectors and the drill-downs build on. + /// + public AnalysisQuery Shown(AnalysisQuery query) + { + ArgumentNullException.ThrowIfNull(query); + + return query.WithScope(Scope).WithMetric(Metric == NaturalMetric ? null : Metric); + } + + /// Reads against . + public static AnalysisSelection Resolve(AnalysisQuery query, AnalysisPageOptions options) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(options); + + return query.Scope.Kind switch + { + QueryScopeKind.EnergyType => ForType(query, options), + QueryScopeKind.Category => ForCategory(query, options), + QueryScopeKind.Meter => ForMeter(query, options), + QueryScopeKind.Meters => ForMeters(query, options), + _ => ForPortfolio(query, options), + }; + } + + /// The metric a scope's natural choice is: consumption when offered, else the first quantity, else the cost. + private static AnalysisMetric NaturalOf(IReadOnlyList quantities) => + quantities.Contains(AnalysisMetric.Consumption) ? AnalysisMetric.Consumption : quantities.Count > 0 ? quantities[0] : AnalysisMetric.Cost; + + private static AnalysisSelection ForPortfolio(AnalysisQuery query, AnalysisPageOptions options) + { + var quantities = options.Types.SelectMany(t => t.QuantityMetrics).ToHashSet(); + List metrics = [AnalysisMetric.Cost, .. AnalysisPageOptions.QuantityOrder.Where(quantities.Contains)]; + return WithMetric(new AnalysisSelection(QueryScope.Portfolio, null) { Metrics = metrics, NaturalMetric = AnalysisMetric.Cost }, query.Metric); + } + + private static AnalysisSelection ForType(AnalysisQuery query, AnalysisPageOptions options) + { + if (options.Type(query.Scope.Id) is not { } type) + { + return Refused(query.Scope, AnalysisPageRefusal.UnknownScope); + } + + List metrics = [.. type.QuantityMetrics, AnalysisMetric.Cost]; + var selection = new AnalysisSelection(query.Scope, null) + { + Metrics = metrics, + NaturalMetric = NaturalOf(type.QuantityMetrics), + ScopeName = type.Name, + EnergyTypeId = type.Id, + }; + return WithMetric(selection, query.Metric); + } + + private static AnalysisSelection ForCategory(AnalysisQuery query, AnalysisPageOptions options) + { + if (options.Category(query.Scope.Id) is not { } category) + { + return Refused(query.Scope, AnalysisPageRefusal.UnknownScope); + } + + var members = category.MeterIds.Select(id => options.Meter(id)).OfType().ToList(); + var groups = GroupsOf(members); + + // A quantity only when every meter measures one kind in one unit (brief §7.4). + var single = groups.Count == 1 && groups[0].Metric is { } only ? only : (AnalysisMetric?)null; + List metrics = single is { } metric ? [AnalysisMetric.Cost, metric] : [AnalysisMetric.Cost]; + var selection = new AnalysisSelection(query.Scope, AnalysisMetric.Cost) + { + Metrics = metrics, + NaturalMetric = AnalysisMetric.Cost, + ScopeName = category.Name, + Groups = groups, + }; + + if (query.Metric is not { } requested || requested == AnalysisMetric.Cost) + { + return selection; + } + + if (!requested.IsQuantity() || (single is { } supported && supported != requested)) + { + // A metric its meters do not measure (or the tank balance): the cost, with a notice. + return selection with { Notices = [new AnalysisPageNotice(AnalysisPageNoticeKind.MetricNotAvailable, requested, AnalysisMetric.Cost)] }; + } + + // The quantity asked for cannot be one: explained, never silently shown as the cost. + var refusal = members.Count == 0 ? AnalysisPageRefusal.CategoryWithoutMeters + : single is null ? AnalysisPageRefusal.CategoryMixed + : members.Count > AnalysisLimits.MaxSeries ? AnalysisPageRefusal.CategoryTooManyMeters + : AnalysisPageRefusal.None; + + return selection with + { + Metric = requested, + SeriesMeterIds = refusal == AnalysisPageRefusal.None ? [.. members.Select(m => m.Id)] : [], + Refusal = refusal, + }; + } + + private static AnalysisSelection ForMeter(AnalysisQuery query, AnalysisPageOptions options) + { + if (options.Meter(query.Scope.Id) is not { } meter) + { + return Refused(query.Scope, AnalysisPageRefusal.UnknownScope); + } + + List metrics = []; + if (meter.Metric is { } own) + { + metrics.Add(own); + } + + if (meter.IsCostable) + { + metrics.Add(AnalysisMetric.Cost); + } + + var selection = new AnalysisSelection(query.Scope, null) + { + Metrics = metrics, + NaturalMetric = meter.Metric, + SeriesMeterIds = [meter.Id], + ScopeName = meter.Name, + EnergyTypeId = meter.EnergyTypeId, + }; + return WithMetric(selection, query.Metric); + } + + private static AnalysisSelection ForMeters(AnalysisQuery query, AnalysisPageOptions options) + { + var requested = query.Scope.MeterIds; + if (requested.Count > AnalysisLimits.MaxSeries) + { + return Refused(query.Scope, AnalysisPageRefusal.TooManyMeters); + } + + var meters = requested.Select(id => options.Meter(id)).OfType().ToList(); + if (meters.Count == 0) + { + return Refused(query.Scope, AnalysisPageRefusal.UnknownScope); + } + + List notices = meters.Count < requested.Count ? [new AnalysisPageNotice(AnalysisPageNoticeKind.UnknownMetersLeftOut)] : []; + var scope = QueryScope.ForMeters(meters.Select(m => m.Id)); + + List metrics = + [ + .. meters.Select(m => m.Metric).OfType().Distinct().OrderBy(IndexOf), + ]; + if (meters.Any(m => m.IsCostable)) + { + metrics.Add(AnalysisMetric.Cost); + } + + var selection = WithMetric( + new AnalysisSelection(scope, null) { Metrics = metrics, NaturalMetric = meters[0].Metric, Notices = notices }, + query.Metric); + + // A meter measures what it measures: the metric picks which of the selected meters are compared. + var shown = selection.IsCost + ? meters.Where(m => m.IsCostable).ToList() + : meters.Where(m => m.Metric == selection.Metric).ToList(); + return selection with + { + SeriesMeterIds = [.. shown.Select(m => m.Id)], + HiddenMeterIds = [.. meters.Except(shown).Select(m => m.Id)], + }; + } + + /// The metric shown for a requested one: the request when supported, else the natural metric with a notice. + private static AnalysisSelection WithMetric(AnalysisSelection selection, AnalysisMetric? requested) + { + if (requested is not { } metric || metric == selection.NaturalMetric) + { + return selection with { Metric = selection.NaturalMetric }; + } + + if (selection.Metrics.Contains(metric)) + { + return selection with { Metric = metric }; + } + + return selection with + { + Metric = selection.NaturalMetric, + Notices = [.. selection.Notices, new AnalysisPageNotice(AnalysisPageNoticeKind.MetricNotAvailable, metric, selection.NaturalMetric)], + }; + } + + private static AnalysisSelection Refused(QueryScope scope, AnalysisPageRefusal refusal) => + new(scope, null) { Refusal = refusal }; + + private static List GroupsOf(IEnumerable meters) => + [ + .. meters + .GroupBy(m => (m.Kind, Unit: Units.Normalize(m.Unit))) + .Select(g => new AnalysisMeterGroup(g.Key.Kind, g.Key.Unit, [.. g.Select(m => m.Id)])), + ]; + + private static int IndexOf(AnalysisMetric metric) + { + var index = AnalysisPageOptions.QuantityOrder.ToList().IndexOf(metric); + return index < 0 ? int.MaxValue : index; + } +} diff --git a/src/App/Api/ApiEndpoints.cs b/src/App/Api/ApiEndpoints.cs index 18403f6..abb6f27 100644 --- a/src/App/Api/ApiEndpoints.cs +++ b/src/App/Api/ApiEndpoints.cs @@ -1,3 +1,5 @@ +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Quantities; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Dashboard; @@ -20,6 +22,32 @@ public sealed record TariffPush(TariffScope ScopeType, int? ScopeId, TariffCompo public sealed record IngestResult(int Written, int Updated, int Rejected, int Ignored); +/// +/// A price a cost figure needed and did not get (D-38), as /api/v1/cost reports it in missingPrices: +/// what to price, why it is missing, where the price belongs and from which month to which. +/// +public sealed record ApiMissingPrice( + TariffComponent Component, + CostStatus Reason, + TariffScope Scope, + int? ScopeId, + int? MeterId, + DateOnly FirstMonth, + DateOnly LastMonth, + int? TariffId, + TariffUnitIssue Issue, + bool IsCredit) +{ + public static ApiMissingPrice Of(MissingPrice price) + { + ArgumentNullException.ThrowIfNull(price); + + return new ApiMissingPrice( + price.Component, price.Reason, price.Scope, price.ScopeId, price.MeterId, price.FirstMonth, price.LastMonth, + price.TariffId, price.Issue, price.IsCredit); + } +} + /// Maps the versioned REST API. All endpoints require a valid API key (SDD §9). public static class ApiEndpoints { @@ -101,19 +129,43 @@ public static class ApiEndpoints .Select(t => new { t.Id, t.Key, t.DisplayName, t.BaseUnit, Mode = t.DefaultMode.ToString() }) .ToListAsync(ct))); + // /consumption, /cost and /dashboard/summary are contracts other systems read (D-45, ApiContractTests): every field + // keeps its name and type, and what the analysis rework adds arrives as new fields. The numbers follow the new + // engine — actuals stop at now, virtual meters are evaluated, costs are the bill's — which the release notes list. api.MapGet("/consumption", async (int meter, DateTimeOffset from, DateTimeOffset to, CostService cost, CancellationToken ct) => { - var buckets = await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct); - return Results.Ok(buckets.Select(b => new { b.Period, b.Consumption, b.Generation })); - }).WithSummary("Normalized monthly consumption/generation for a meter."); + // Npgsql accepts only UTC instants for timestamptz; a caller's local offset must not be a 500. + var buckets = await cost.GetMeterCostsAsync(meter, from.ToUniversalTime(), to.ToUniversalTime(), CostBucket.Month, ct); + // Months that exist only for a cost (a standing charge through a reading gap) are no consumption rows. + return Results.Ok(buckets.Where(b => b.HasQuantity).Select(b => new { b.Period, b.Consumption, b.Generation, b.Status, b.Issue, b.Kind, b.Unit })); + }).WithSummary("Normalized monthly consumption/generation for a meter (virtual meters evaluated), with each month's status."); api.MapGet("/cost", async (int meter, DateTimeOffset from, DateTimeOffset to, CostService cost, CancellationToken ct) => - Results.Ok(await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct))); + { + var buckets = await cost.GetMeterCostsAsync(meter, from.ToUniversalTime(), to.ToUniversalTime(), CostBucket.Month, ct); + return Results.Ok(buckets.Select(b => new + { + b.Period, + b.Consumption, + b.Generation, + b.Cost, + b.CostStatus, + b.CostAvailability, + b.CostRule, + b.NotCosted, + MissingPrices = b.MissingPrices.Select(ApiMissingPrice.Of), + b.Status, + b.Issue, + b.Kind, + b.Unit, + })); + }).WithSummary("Monthly cost of a meter by its cost rule (costRule; notCosted says why a meter has none), with its price coverage (costStatus, missingPrices) and whether the cost is known (costAvailability)."); - api.MapGet("/dashboard/summary", async (DashboardService dashboard, CancellationToken ct) => - Results.Ok(await dashboard.GetSummaryAsync(DateOnly.FromDateTime(DateTime.UtcNow), ct))); + api.MapGet("/dashboard/summary", async (DashboardService dashboard, TimeProvider time, CancellationToken ct) => + Results.Ok(await dashboard.GetSummaryAsync(time.GetUtcNow(), ct))) + .WithSummary("Overview KPIs: this calendar month and year to now against the whole previous ones (legacy windows), priced as the bill."); api.MapPost("/events", async (EventPush push, MeterVaultDbContext db, NormalizationService normalization, CancellationToken ct) => diff --git a/src/App/BrowserPreferences.cs b/src/App/BrowserPreferences.cs new file mode 100644 index 0000000..227279f --- /dev/null +++ b/src/App/BrowserPreferences.cs @@ -0,0 +1,53 @@ +using Microsoft.JSInterop; + +namespace MeterVault.App; + +/// +/// UI preferences kept in cookies (D-48, D-49) so the server renders them right from the first byte: the theme and the +/// expanded navigation groups. App.razor reads them on every full request and hands them to the interactive +/// root; the circuit writes them back through the tiny script helper in wwwroot/metervault.js. +/// +/// +/// A cookie rather than local storage because the server needs the value while prerendering: a theme stored only in +/// the browser would flash dark-then-light on every reload and reset on the language switch, which is a full reload. +/// The cookies are plain preferences (not HttpOnly, SameSite=Lax, one year, path /), written only by the script, whose +/// whitelist accepts nothing but these two names. +/// +public static class BrowserPreferences +{ + /// The theme cookie: dark or light. + public const string ThemeCookie = "mv-theme"; + + /// The expanded navigation groups (). + public const string NavCookie = "mv-nav"; + + /// + /// Writes a preference cookie from the circuit. A missing or disconnected browser (prerender, a closed tab) only + /// loses the preference, never the circuit. + /// + public static async Task SaveAsync(IJSRuntime js, string name, string value) + { + ArgumentNullException.ThrowIfNull(js); + + try + { + await js.InvokeAsync("meterVault.setPreference", name, value); + } + catch (JSDisconnectedException) + { + // The browser went away; there is nobody to remember the preference for. + } + catch (JSException) + { + // The helper script failed to load (blocked, stale cache): the preference lasts for this circuit only. + } + catch (InvalidOperationException) + { + // Prerendering: no browser yet. Preferences are only changed interactively, so this is a programming slip. + } + catch (TaskCanceledException) + { + // The call timed out or the circuit is shutting down. + } + } +} diff --git a/src/App/Components/App.razor b/src/App/Components/App.razor index 04096f6..635d0d7 100644 --- a/src/App/Components/App.razor +++ b/src/App/Components/App.razor @@ -1,5 +1,6 @@ -@using System.Globalization +@using System.Globalization @using Microsoft.AspNetCore.Localization +@using MeterVault.App.Theme @@ -17,26 +18,43 @@ - + @* The theme and the expanded navigation groups come from cookies read here, on the server, and ride into the + interactive root as parameters: they survive prerender -> circuit, so a reload or the language switch (a full + reload) renders the chosen mode from the first byte instead of flashing the default (D-48, D-49). *@ + - - + @* Blazor-ApexCharts 6.x imports its own ES modules (js/apexcharts.esm.js, js/blazor-apexcharts.js) when a chart + first renders; the old apex-charts.min.js / blazor-apex-charts.min.js bundles no longer ship, so they are not + referenced. *@ + @code { + private bool _darkMode = ThemeState.DefaultIsDark; + private string? _navGroups; + [CascadingParameter] private HttpContext? HttpContext { get; set; } - // Pin whatever the middleware negotiated (Accept-Language, or the configured default) into the - // culture cookie on the very first visit. Without this the language picker would be the only - // thing that ever writes the cookie, so a reader whose browser asked for German would be served - // German until the moment they touched the picker — and the picker would open showing English. - protected override void OnInitialized() => - HttpContext?.Response.Cookies.Append( + protected override void OnInitialized() + { + if (HttpContext is null) + { + return; + } + + _darkMode = ThemeState.Parse(HttpContext.Request.Cookies[BrowserPreferences.ThemeCookie]) ?? ThemeState.DefaultIsDark; + _navGroups = HttpContext.Request.Cookies[BrowserPreferences.NavCookie]; + + // Pin whatever the middleware negotiated (Accept-Language, or the configured default) into the + // culture cookie on the very first visit. Without this the language picker would be the only + // thing that ever writes the cookie, so a reader whose browser asked for German would be served + // German until the moment they touched the picker — and the picker would open showing English. + HttpContext.Response.Cookies.Append( CookieRequestCultureProvider.DefaultCookieName, CookieRequestCultureProvider.MakeCookieValue( new RequestCulture(CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture)), @@ -48,4 +66,5 @@ HttpOnly = true, IsEssential = true, }); + } } diff --git a/src/App/Components/Layout/MainLayout.razor b/src/App/Components/Layout/MainLayout.razor index a8c5156..4266bc0 100644 --- a/src/App/Components/Layout/MainLayout.razor +++ b/src/App/Components/Layout/MainLayout.razor @@ -1,10 +1,14 @@ @inherits LayoutComponentBase +@implements IDisposable @using System.Globalization @using MeterVault.App.Theme @inject NavigationManager Navigation @inject IDialogService DialogService +@inject ThemeState Theme - +@* The mode lives in the scoped ThemeState (D-49): App.razor seeds it from the cookie, so prerender, reload and the + language switch keep it, and charts observe the same value. *@ + @@ -16,7 +20,11 @@ MeterVault - + @* Search is the fastest way to a meter from anywhere: labelled where there is room (md and up), an icon with + an accessible name below. *@ + @S.Layout_FindMeter + @@ -33,9 +41,9 @@ } - - + + @@ -58,16 +66,22 @@ @code { private bool _drawerOpen = true; - private bool _darkMode = true; private string _current = Loc.SupportedCultures[0]; - protected override void OnInitialized() => + /// What the theme button does: switch to the other mode. + private string ThemeToggleLabel => Theme.IsDark ? S.Layout_LightMode : S.Layout_DarkMode; + + protected override void OnInitialized() + { Loc.TryResolve(CultureInfo.CurrentUICulture.Name, out _current); + Theme.Changed += OnThemeChanged; + } + + private void OnThemeChanged() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() => Theme.Changed -= OnThemeChanged; - // A circuit is stuck with the culture it was opened under, so changing language is a real - // navigation: the endpoint writes the cookie and forceLoad tears the circuit down so the - // reload comes back translated. Returning to the current path keeps the reader in place. private async Task OpenMeterSearchAsync() => await DialogService.ShowAsync(S.Layout_FindMeter, new DialogOptions { @@ -78,6 +92,10 @@ Position = DialogPosition.TopCenter, }); + // A circuit is stuck with the culture it was opened under, so changing language is a real + // navigation: the endpoint writes the cookie and forceLoad tears the circuit down so the + // reload comes back translated. Returning to the current path keeps the reader in place, and + // the theme cookie keeps the mode. private void SwitchCulture(string culture) { if (culture == _current) diff --git a/src/App/Components/Layout/NavMenu.razor b/src/App/Components/Layout/NavMenu.razor index d8f4f2a..0a7e7d5 100644 --- a/src/App/Components/Layout/NavMenu.razor +++ b/src/App/Components/Layout/NavMenu.razor @@ -2,32 +2,66 @@ @inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory @inject NavigationManager Navigation @inject NavState NavState +@inject IJSRuntime JS +@inject ILogger Logger @using Microsoft.AspNetCore.Components.Routing @using Microsoft.EntityFrameworkCore @using MeterVault.Core.Domain -@* Grouped by what the user came to do: look at the numbers, work with meters and data (readings, - swaps and imports all start from a meter or an import), or configure the instance. *@ +@* One stable structure (D-48, brief §3.1): the analysis entries first, the per-type analysis and the specialized views + as groups, then data import and the configuration pages. "Energy types" here opens a type's analysis; the entry of + the same name under Configuration edits the definitions. Expanded groups persist in a cookie, and the group holding + the current page is always open. *@ @S.Nav_Overview - @S.Nav_Trends - - @S.Nav_SectionData + @S.Nav_Analysis @S.Nav_Meters + + + @if (_typesFailed) + { + @* A database hiccup keeps the group and says so, with a way to try again — never a silently shorter menu. *@ + @S.Nav_EnergyTypesError + @S.Common_Retry + } + else if (_energyTypes is { Count: 0 }) + { + @S.Nav_NoEnergyTypes + } + else if (_energyTypes is not null) + { + @foreach (var type in _energyTypes) + { + @type.DisplayName + } + } + + + @* Always listed; without the meters a view needs, it says what is missing instead of disappearing. What counts is + the meters' configured mode and tank, never their names. *@ + + + @S.Nav_Solar + @if (_setup is { HasGeneration: false }) + { + @S.Nav_SolarSetup + } + + + @S.Nav_Consumables + @if (_setup is { HasTank: false }) + { + @S.Nav_ConsumablesSetup + } + + + @S.Nav_Import - @S.Nav_SectionEnergy - @foreach (var type in _energyTypes) - { - @type.DisplayName - } - @S.Nav_Solar - @S.Nav_Consumables - - - @* Opens by itself on an admin page — a bookmark, a reload, a language switch or a link from - elsewhere would otherwise land with the current page folded away out of sight. *@ - + @S.Nav_EnergyTypes @S.Nav_Tariffs @S.Nav_CostCategories @@ -37,40 +71,85 @@ @code { - private List _energyTypes = []; - private bool _adminExpanded; + private List? _energyTypes; + private bool _typesFailed; + private ViewSetup? _setup; + private HashSet _expanded = new(StringComparer.Ordinal); protected override async Task OnInitializedAsync() { - _adminExpanded = IsAdminPage(Navigation.Uri); + _expanded = new HashSet((IEnumerable?)NavState.SavedGroups ?? NavGroups.DefaultExpanded, StringComparer.Ordinal); + OpenGroupOf(Navigation.Uri); + Navigation.LocationChanged += OnLocationChanged; NavState.EnergyTypesChanged += OnEnergyTypesChanged; + NavState.MetersChanged += OnMetersChanged; + await LoadEnergyTypesAsync(); + await LoadSetupAsync(); } + private bool IsExpanded(string group) => _expanded.Contains(group); + + /// A group folded or opened by the user: remembered for the next visit. + private async Task SetExpandedAsync(string group, bool expanded) + { + var changed = expanded ? _expanded.Add(group) : _expanded.Remove(group); + if (changed) + { + await BrowserPreferences.SaveAsync(JS, BrowserPreferences.NavCookie, NavGroups.Format(_expanded)); + } + } + + /// Opens the group holding the page at ; never folds one (that is the user's call). + private bool OpenGroupOf(string uri) => + NavGroups.GroupFor(Navigation.ToBaseRelativePath(uri)) is { } group && _expanded.Add(group); + private async Task LoadEnergyTypesAsync() { try { await using var db = await DbFactory.CreateDbContextAsync(); _energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync(); + _typesFailed = false; } - catch (Exception) + catch (Exception ex) { - // Nav must never break the layout — a DB hiccup just hides the per-type links. - _energyTypes = []; + // The layout must never break over the menu: keep the group, say it failed, offer Retry — and log why. + Logger.LogError(ex, "Loading the energy types for the navigation failed"); + _energyTypes = null; + _typesFailed = true; } } - private bool IsAdminPage(string uri) => - Navigation.ToBaseRelativePath(uri).StartsWith("admin/", StringComparison.OrdinalIgnoreCase); + /// Whether the specialized views have what they need: a generation counter for Solar, a tank for Consumables. + private async Task LoadSetupAsync() + { + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var hasGeneration = await db.Meters.AnyAsync(m => m.Mode == MeterMode.GenerationCounter); + var hasTank = await db.Tanks.AnyAsync(t => db.Meters.Any(m => m.Id == t.MeterId && m.Mode == MeterMode.ConsumableBalance)); + _setup = new ViewSetup(hasGeneration, hasTank); + } + catch (Exception ex) + { + // Unknown is shown as nothing: the entries stay, without a setup hint. + Logger.LogError(ex, "Checking the specialized views' setup for the navigation failed"); + _setup = null; + } + } + + private async Task RetryEnergyTypesAsync() + { + await LoadEnergyTypesAsync(); + StateHasChanged(); + } - // Opens only: collapsing it again when the user leaves is their call, not the menu's. private void OnLocationChanged(object? sender, LocationChangedEventArgs e) { - if (!_adminExpanded && IsAdminPage(e.Location)) + if (OpenGroupOf(e.Location)) { - _adminExpanded = true; _ = InvokeAsync(StateHasChanged); } } @@ -82,10 +161,18 @@ StateHasChanged(); }); + private void OnMetersChanged() => + _ = InvokeAsync(async () => + { + await LoadSetupAsync(); + StateHasChanged(); + }); + public void Dispose() { Navigation.LocationChanged -= OnLocationChanged; NavState.EnergyTypesChanged -= OnEnergyTypesChanged; + NavState.MetersChanged -= OnMetersChanged; } // Map the energy type's stored icon name to a Material icon; fall back to a bolt. @@ -98,4 +185,6 @@ "thermostat" => Icons.Material.Filled.Thermostat, _ => Icons.Material.Filled.Bolt, }; + + private sealed record ViewSetup(bool HasGeneration, bool HasTank); } diff --git a/src/App/Components/Pages/Admin/EnergyTypes.razor b/src/App/Components/Pages/Admin/EnergyTypes.razor index b4a5e83..b51c94c 100644 --- a/src/App/Components/Pages/Admin/EnergyTypes.razor +++ b/src/App/Components/Pages/Admin/EnergyTypes.razor @@ -6,14 +6,17 @@ @using Microsoft.EntityFrameworkCore @using MudBlazor -MeterVault — @S.Nav_EnergyTypes +MeterVault — @S.EnergyTypes_Title -
- @S.Nav_EnergyTypes +@* Configuration, not analysis: the menu's "Energy types" group opens each type's analysis, this page edits what a type + is. The title and the line below say which one the reader is on. *@ +
+ @S.EnergyTypes_Title @S.EnergyTypes_Add
+@S.EnergyTypes_Description @if (_types is null) { @@ -162,11 +165,9 @@ else return; } - var target = await db.EnergyTypes.FirstOrDefaultAsync(t => t.Id == type.Id); - if (target is not null) + // Its type-scoped prices go with it: tariff.scope_id has no foreign key. + if (await MeterVault.Infrastructure.Persistence.EntityDeletion.DeleteEnergyTypeAsync(db, type.Id)) { - db.EnergyTypes.Remove(target); - await db.SaveChangesAsync(); Snackbar.Add(S.Common_Deleted, Severity.Success); NavState.NotifyEnergyTypesChanged(); } diff --git a/src/App/Components/Pages/Admin/Settings.razor b/src/App/Components/Pages/Admin/Settings.razor index 7755e9d..bc4e656 100644 --- a/src/App/Components/Pages/Admin/Settings.razor +++ b/src/App/Components/Pages/Admin/Settings.razor @@ -1,10 +1,17 @@ @page "/admin/settings" +@using System.Text.Json +@using Microsoft.EntityFrameworkCore +@using MeterVault.Infrastructure.Analysis +@using MeterVault.Infrastructure.Normalization +@using MeterVault.Infrastructure.Persistence @inject Microsoft.Extensions.Options.IOptions Options +@inject IDbContextFactory DbFactory +@inject AnalysisReader Reader +@inject InstanceCurrency Currency +@inject ILogger Logger @using MudBlazor -MeterVault — @S.Nav_Settings - -@S.Nav_Settings + @S.Settings_EffectiveLead @S.Settings_EffectiveEmphasis @S.Settings_EffectiveRest (MeterVault__Key / Section__Key) @S.Settings_EffectiveTail @@ -16,13 +23,20 @@ @S.Settings_LocaleAndTime - @S.Settings_Timezone@_o.TimeZone + @S.Settings_Timezone@Reader.Zone.Id @S.Settings_Locale@_o.Locale - @S.Common_Currency@_o.Currency - @S.Settings_RawRetention@Loc.F(S.Settings_RetentionDays, _o.RawRetentionDays) + @S.Common_Currency@Currency.Code (@Currency.Symbol) + + @S.Settings_RawRetention + + @* D-57: every recompute rebuilds a meter from its readings, so deleting old ones would erase history. *@ + @S.Settings_RetentionNotEnforced + + - + @Loc.F(S.Settings_RetentionReason, _o.RawRetentionDays) + @S.Settings_EnvKeysLabel MeterVault__TimeZone, MeterVault__Locale, MeterVault__Currency, MeterVault__RawRetentionDays. @@ -56,16 +70,137 @@ @S.Settings_SeedReferenceData@(_o.SeedReferenceData ? S.Settings_On : S.Settings_Off) - + @S.Settings_ApiKeysHintBefore MeterVault__ApiKeys__0. @S.Settings_ApiKeysHintAfter @S.Settings_ApiDocsLabel /swagger. + + + + @S.Settings_AnalysisData + @if (_analysis is { } state) + { + + + @S.Settings_NormalizationRevision@RevisionText(state) + @S.Settings_NormalizationZone@ZoneText(state) + + @S.Settings_MetersCurrent + + @Loc.F(S.Settings_MetersCurrentValue, state.CurrentMeters, state.PhysicalMeters) + @if (state.PhysicalMeters > state.CurrentMeters) + { + · @Loc.F(S.Settings_MetersPending, state.PhysicalMeters - state.CurrentMeters) + } + + + @if (state.Retry.Count > 0) + { + @S.Settings_RebuildRetry@string.Join(", ", state.Retry) + } + @S.Settings_VirtualMeters@VirtualText(state) + + + } + else if (_analysisFailed) + { + @S.Settings_AnalysisUnavailable + } + @S.Settings_AnalysisHelp + + @code { private MeterVault.Infrastructure.Options.MeterVaultOptions _o = new(); + private AnalysisState? _analysis; + private bool _analysisFailed; - protected override void OnInitialized() => _o = Options.Value; + /// What the analysis data is built with (D-16) and how the calculated meters stand (D-26, D-28). + private sealed record AnalysisState( + int? Revision, + string? Zone, + int PhysicalMeters, + int CurrentMeters, + IReadOnlyList Retry, + IReadOnlyList<(VirtualMeterStatus Status, int Count)> Virtual); + + protected override async Task OnInitializedAsync() + { + _o = Options.Value; + try + { + _analysis = await LoadAnalysisStateAsync(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The settings themselves are configuration and always show; only this read-out depends on the database. + Logger.LogWarning(ex, "Could not read the analysis data state"); + _analysisFailed = true; + } + } + + private async Task LoadAnalysisStateAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + var settings = await db.AppSettings.AsNoTracking() + .Where(s => s.Key == NormalizationUpgrade.SettingKey || s.Key == NormalizationUpgrade.ZoneSettingKey || s.Key == NormalizationUpgrade.PendingSettingKey) + .ToDictionaryAsync(s => s.Key, s => s.Value); + + var catalog = await Reader.LoadCatalogAsync(); + var physical = catalog.Meters.Values.Where(m => !m.IsVirtual).ToList(); + var retryIds = Read(settings, NormalizationUpgrade.PendingSettingKey) ?? []; + var virtualStates = catalog.Meters.Values + .Where(m => m.IsVirtual) + .GroupBy(m => m.VirtualStatus ?? VirtualMeterStatus.NeedsConfiguration) + .OrderBy(g => g.Key) + .Select(g => (g.Key, g.Count())) + .ToList(); + + return new AnalysisState( + Read(settings, NormalizationUpgrade.SettingKey), + Read(settings, NormalizationUpgrade.ZoneSettingKey), + physical.Count, + physical.Count(m => !m.IsPending), + [.. retryIds.Select(id => catalog.Find(id)?.Name ?? $"#{id}")], + virtualStates); + } + + private static T? Read(IReadOnlyDictionary settings, string key) + { + if (!settings.TryGetValue(key, out var json)) + { + return default; + } + + try + { + return JsonSerializer.Deserialize(json); + } + catch (JsonException) + { + return default; + } + } + + private static string RevisionText(AnalysisState state) => state.Revision switch + { + null => S.Settings_RevisionNone, + var revision when revision < NormalizationUpgrade.CurrentRevision => + Loc.F(S.Settings_RevisionOutdated, revision, NormalizationUpgrade.CurrentRevision), + var revision => Loc.F(S.Settings_RevisionValue, revision), + }; + + private string ZoneText(AnalysisState state) => state.Zone switch + { + null => S.Settings_RevisionNone, + var zone when !string.Equals(zone, Reader.Zone.Id, StringComparison.Ordinal) => Loc.F(S.Settings_ZoneDiffers, zone), + var zone => zone, + }; + + private static string VirtualText(AnalysisState state) => state.Virtual.Count == 0 + ? S.Settings_None + : string.Join(" · ", state.Virtual.Select(v => $"{v.Status.Display()}: {v.Count}")); } diff --git a/src/App/Components/Pages/Admin/Tariffs.razor b/src/App/Components/Pages/Admin/Tariffs.razor index 34ec480..059df64 100644 --- a/src/App/Components/Pages/Admin/Tariffs.razor +++ b/src/App/Components/Pages/Admin/Tariffs.razor @@ -1,18 +1,37 @@ @page "/admin/tariffs" -@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory -@inject ISnackbar Snackbar -@inject IDialogService DialogService @using Microsoft.EntityFrameworkCore @using MudBlazor +@using MeterVault.App.TariffEditing +@using MeterVault.Core.Analysis.Quantities +@using MeterVault.Infrastructure.Analysis +@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory +@inject AnalysisReader Reader +@inject InstanceCurrency Currency +@inject InstanceClock Clock +@inject NavigationManager Nav +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject ILogger Logger -MeterVault — @S.Nav_Tariffs + + + + @S.Tariffs_AddTariff + + + +@S.Tariffs_NotAppliedNote -
- @S.Nav_Tariffs - - @S.Tariffs_AddTariff - -
+@if (_link.HasScope) +{ + @* D-52: a link from a missing-cost explanation scopes the list to what can price that meter or type. *@ + +
+ @FilterText + @S.Tariffs_ShowAll +
+
+} @if (_tariffs is null) { @@ -20,7 +39,8 @@ } else { - + var shown = _tariffs.Where(t => _link.Lists(t, MeterEnergyType)).ToList(); + @S.Common_Scope @S.Tariffs_Component @@ -32,14 +52,30 @@ else @ScopeLabel(context) - @context.Component.Display() + + @context.Component.Display() + @if (IsNotApplied(context.Component)) + { + @S.Tariffs_NotAppliedChip + } + @Format.Number(context.Value, 4) - @context.Unit - @context.ValidFrom.ToString("yyyy-MM-dd") - @(context.ValidTo?.ToString("yyyy-MM-dd") ?? S.Tariffs_OpenEnded) + + @context.Unit + @if (RowIssue(context) is { } issue) + { + + + + } + + @Format.Date(context.ValidFrom) + @(_effectiveEnds.GetValueOrDefault(context.Id) is { } until ? Format.Date(until) : S.Tariffs_OpenEnded) - - + + @@ -47,6 +83,10 @@ else { @S.Tariffs_EmptyState @S.Nav_Import. } + else if (shown.Count == 0) + { + @S.Tariffs_FilterEmpty + } } @@ -54,7 +94,7 @@ else @(_working.Id == 0 ? S.Tariffs_NewTariff : S.Tariffs_EditTariff) - + @foreach (var scope in Enum.GetValues()) { @scope.Display() @@ -62,7 +102,7 @@ else @if (_working.ScopeType == TariffScope.EnergyType) { - + @foreach (var t in _energyTypes) { @t.DisplayName @@ -71,21 +111,32 @@ else } else if (_working.ScopeType == TariffScope.Meter) { - + @foreach (var m in _meters) { @m.Name } } - + @foreach (var component in Enum.GetValues()) { @component.Display() } - - + @* D-38: a new tariff starts without a value, and saving needs one; a typed 0 is a deliberate free price, said so. *@ + + + @* D-37: how the unit was read and whether it fits what this tariff would price, before it is saved. *@ +
+ @foreach (var (isError, text) in TariffUnitCheck.Describe(Verdict, Currency.Code)) + { + @text + } +
@@ -99,20 +150,104 @@ else @code { private List? _tariffs; + private IReadOnlyDictionary _effectiveEnds = new Dictionary(); private List _energyTypes = []; private List _meters = []; + private AnalysisCatalog? _catalog; private bool _editOpen; private EditModel _working = new(); private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; + private TariffDeepLink _link = TariffDeepLink.None; + + /// A new-tariff request from the link, held until the action has been dropped from the address. + private TariffDeepLink? _pendingNew; + + private bool _droppingAction; + + [SupplyParameterFromQuery(Name = TariffLinks.ParamScope)] + public string? ScopeParam { get; set; } + + [SupplyParameterFromQuery(Name = TariffLinks.ParamId)] + public string? IdParam { get; set; } + + [SupplyParameterFromQuery(Name = TariffLinks.ParamComponent)] + public string? ComponentParam { get; set; } + + [SupplyParameterFromQuery(Name = TariffLinks.ParamFrom)] + public string? FromParam { get; set; } + + /// A dialog to open once the page is interactive (new); dropped from the address when consumed. + [SupplyParameterFromQuery(Name = TariffLinks.ParamAction)] + public string? Action { get; set; } + protected override Task OnInitializedAsync() => LoadAsync(); + protected override void OnParametersSet() + { + _link = TariffDeepLink.Parse(ScopeParam, IdParam, ComponentParam, FromParam, Action); + if (_link.OpenNew) + { + _pendingNew = _link; + } + else if (string.IsNullOrEmpty(Action)) + { + _droppingAction = false; + } + } + + /// + /// Opens the deep-linked new-tariff dialog once the page is interactive (D-52). As on a meter's page, the action is + /// dropped from the address first and the dialog opened when that navigation has come back: a circuit's first + /// location change would otherwise dismiss the dialog, and a reload must not reopen it. The scope stays in the + /// address, so the list stays scoped. + /// + protected override void OnAfterRender(bool firstRender) + { + if (_pendingNew is not { } request || _tariffs is null) + { + return; + } + + if (!string.IsNullOrEmpty(Action)) + { + if (!_droppingAction) + { + _droppingAction = true; + Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary + { + [TariffLinks.ParamAction] = null, + [TariffLinks.ParamComponent] = null, + [TariffLinks.ParamFrom] = null, + }), replace: true); + } + + return; + } + + _pendingNew = null; + OpenNew(request); + StateHasChanged(); + } + private async Task LoadAsync() { await using var db = await DbFactory.CreateDbContextAsync(); _tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync(); + _effectiveEnds = TariffValidity.EffectiveEnds( + _tariffs.Select(t => new TariffSpan(t.Id, t.ScopeType, t.ScopeId, t.Component, t.ValidFrom, t.ValidTo))); _energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync(); _meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync(); + try + { + _catalog = await Reader.LoadCatalogAsync(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Without it the unit is still parsed; only the check against the scope's units is skipped. + Logger.LogWarning(ex, "Could not load the meter catalog for the tariff unit check"); + _catalog = null; + } } private string ScopeLabel(Tariff t) => t.ScopeType switch @@ -123,26 +258,124 @@ else _ => t.ScopeType.ToString(), }; + private string FilterText => _link.Scope switch + { + TariffScope.Meter => Loc.F(S.Tariffs_FilterMeter, _meters.FirstOrDefault(m => m.Id == _link.ScopeId)?.Name ?? $"#{_link.ScopeId}"), + TariffScope.EnergyType => Loc.F(S.Tariffs_FilterType, _energyTypes.FirstOrDefault(t => t.Id == _link.ScopeId)?.DisplayName ?? $"#{_link.ScopeId}"), + _ => S.Tariffs_FilterGlobal, + }; + + private int? MeterEnergyType(int meterId) => _meters.FirstOrDefault(m => m.Id == meterId)?.EnergyTypeId; + + private static bool IsNotApplied(TariffComponent component) => + component is TariffComponent.Bonus or TariffComponent.Discount or TariffComponent.Tax; + + private void ShowAll() => Nav.NavigateTo(TariffLinks.Path); + + /// What a tariff of this scope and component would price, by unit (D-20, D-34). + private IReadOnlyList TargetsFor(TariffScope scope, int? scopeId, TariffComponent component) => + _catalog is null + ? [] + : TariffUnitCheck.TargetsFor(_catalog, scope, scopeId, component, id => + _energyTypes.FirstOrDefault(t => t.Id == id) is { } type ? (type.DisplayName, type.BaseUnit) : null); + + private TariffUnitVerdict Verdict => TariffUnitCheck.Check( + _working.Unit, _working.Component, TargetsFor(_working.ScopeType, _working.ScopeId, _working.Component), Currency.Code); + + /// A stored tariff whose unit would be refused or warned about now, with the first line that says why. + private (bool IsError, string Text)? RowIssue(Tariff tariff) + { + var verdict = TariffUnitCheck.Check(tariff.Unit, tariff.Component, TargetsFor(tariff.ScopeType, tariff.ScopeId, tariff.Component), Currency.Code); + if (verdict.Kind is TariffUnitVerdictKind.Fits or TariffUnitVerdictKind.NotApplied) + { + return null; + } + + var lines = TariffUnitCheck.Describe(verdict, Currency.Code); + var reason = lines.FirstOrDefault(l => l.IsError); + return reason.Text is null ? (false, lines[^1].Text) : (true, reason.Text); + } + private void OpenEdit(Tariff? tariff) { - _working = tariff is null - ? new EditModel { ValidFrom = DateTime.Today } - : new EditModel - { - Id = tariff.Id, - ScopeType = tariff.ScopeType, - ScopeId = tariff.ScopeId, - Component = tariff.Component, - Value = tariff.Value, - Unit = tariff.Unit, - Currency = tariff.Currency, - ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue), - ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue), - Notes = tariff.Notes, - }; + if (tariff is null) + { + OpenNew(TariffDeepLink.None with { Scope = _link.Scope, ScopeId = _link.ScopeId }); + return; + } + + _working = new EditModel + { + Id = tariff.Id, + ScopeType = tariff.ScopeType, + ScopeId = tariff.ScopeId, + Component = tariff.Component, + Value = tariff.Value, + Unit = tariff.Unit, + UnitTouched = true, + Currency = tariff.Currency, + ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue), + ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue), + Notes = tariff.Notes, + }; _editOpen = true; } + /// A new tariff, prefilled from a link (D-52): its scope, component and first month, and a unit that fits. + private void OpenNew(TariffDeepLink request) + { + _working = new EditModel + { + ScopeType = request.Scope ?? TariffScope.EnergyType, + ScopeId = request.Scope == TariffScope.Global ? null : request.ScopeId, + Component = request.Component ?? TariffComponent.UnitPrice, + Currency = Currency.Code, + ValidFrom = (request.From ?? Clock.Today).ToDateTime(TimeOnly.MinValue), + }; + SuggestUnit(); + _editOpen = true; + } + + /// A unit nobody typed follows the scope and component: currency per the priced unit, or per month. + private void SuggestUnit() + { + if (_working.UnitTouched) + { + return; + } + + var target = TargetsFor(_working.ScopeType, _working.ScopeId, _working.Component).FirstOrDefault(); + _working.Unit = TariffUnit.Suggest(_working.Component, target?.Unit ?? (_working.Component is TariffComponent.UnitPrice or TariffComponent.FeedIn ? "kWh" : null), Currency.Code); + } + + private void SetScope(TariffScope scope) + { + _working.ScopeType = scope; + _working.ScopeId = null; + SuggestUnit(); + } + + private void SetScopeId(int? id) + { + _working.ScopeId = id; + SuggestUnit(); + } + + private void SetComponent(TariffComponent component) + { + _working.Component = component; + SuggestUnit(); + } + + private void SetUnit(string? unit) + { + _working.Unit = unit ?? ""; + _working.UnitTouched = true; + } + + /// What the value field holds, for the save guard and the note under it (D-38). + private TariffValueVerdict ValueVerdict => TariffValue.Check(_working.Value, _working.Component); + private async Task SaveAsync() { if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null) @@ -157,7 +390,22 @@ else return; } + // D-38: an untouched value is not a price. Without this, the missing-price deep link would save a free period. + if (ValueVerdict.BlocksSave()) + { + Snackbar.Add(S.Tariffs_ValueRequired, Severity.Warning); + return; + } + + // D-37: a unit that is read and does not fit would leave the cost "unavailable (unit)"; refuse it here instead. + if (Verdict.Blocks) + { + Snackbar.Add(S.Tariffs_UnitBlocked, Severity.Warning); + return; + } + var scopeId = _working.ScopeType == TariffScope.Global ? null : _working.ScopeId; + var value = _working.Value!.Value; await using var db = await DbFactory.CreateDbContextAsync(); if (_working.Id == 0) @@ -167,9 +415,9 @@ else ScopeType = _working.ScopeType, ScopeId = scopeId, Component = _working.Component, - Value = _working.Value, + Value = value, Unit = _working.Unit.Trim(), - Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim(), + Currency = string.IsNullOrWhiteSpace(_working.Currency) ? Currency.Code : _working.Currency.Trim(), ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value), ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null, Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes, @@ -181,9 +429,9 @@ else existing.ScopeType = _working.ScopeType; existing.ScopeId = scopeId; existing.Component = _working.Component; - existing.Value = _working.Value; + existing.Value = value; existing.Unit = _working.Unit.Trim(); - existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim(); + existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? Currency.Code : _working.Currency.Trim(); existing.ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value); existing.ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null; existing.Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes; @@ -221,8 +469,14 @@ else public TariffScope ScopeType { get; set; } = TariffScope.EnergyType; public int? ScopeId { get; set; } public TariffComponent Component { get; set; } = TariffComponent.UnitPrice; - public double Value { get; set; } + /// The price; null until one is typed (a new tariff never defaults to a free 0, D-38). + public double? Value { get; set; } + public string Unit { get; set; } = "EUR/kWh"; + + /// True once the user typed a unit (or it is a stored tariff's): it is no longer re-suggested. + public bool UnitTouched { get; set; } + public string Currency { get; set; } = "EUR"; public DateTime? ValidFrom { get; set; } public DateTime? ValidTo { get; set; } diff --git a/src/App/Components/Pages/AnalysisPage/AnalysisCards.razor b/src/App/Components/Pages/AnalysisPage/AnalysisCards.razor new file mode 100644 index 0000000..cf5326d --- /dev/null +++ b/src/App/Components/Pages/AnalysisPage/AnalysisCards.razor @@ -0,0 +1,152 @@ +@using MeterVault.App.AnalysisPage +@using MeterVault.Core.Analysis +@using MeterVault.Core.Analysis.Costing +@using MeterVault.Infrastructure.Analysis +@inject InstanceCurrency Currency + +@* The period figures of the Analysis page (brief §7.4, D-08): one card per series with its total — the status in words + when there is no number, never a zero — and its change against the comparison over the dates both periods cover. A cost + card says what it is made of (metered use, standing charges, manual costs, feed-in credit), so manual costs are visibly + counted once; a meter's cost names its rule (D-39). *@ + + + @if (View.Kind == AnalysisPageViewKind.Quantity) + { + @foreach (var series in View.Series) + { + + + + } + + @if (View.MeterCost is { } meterCost) + { + + + + } + } + else + { + @foreach (var cost in View.Costs) + { + + + + } + } + + +@code { + [Parameter, EditorRequired] + public AnalysisPageView View { get; set; } = null!; + + [Parameter, EditorRequired] + public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty; + + [Parameter, EditorRequired] + public AnalysisQuery Shown { get; set; } = null!; + + private string? ChangeCaption => View.Comparison?.IsApplicable == true ? Shown.Comparison.Display() : null; + + /// A meter: its energy type and what it measures; a measure: the meters counted in it (D-22). + private string? CaptionOf(AnalysisSeries series) + { + if (series.MeterId is { } meterId) + { + var type = series.EnergyTypeId is { } typeId ? Options.TypeName(typeId) : null; + var what = series.Kind.Display(); + var calculated = Options.Meter(meterId) is { IsVirtual: true } ? " · " + S.Analysis_Calculated : string.Empty; + return (type is null ? what : type + " · " + what) + calculated; + } + + return series.MemberIds.Count > 0 + ? Loc.F(S.Analysis_Counted, string.Join(", ", series.MemberIds.Select(Options.MeterName))) + : null; + } + + /// A meter's page, or — on the portfolio — the energy type in this page. + private string? HrefOf(AnalysisSeries series) + { + if (series.MeterId is { } meterId) + { + return View.Selection.Scope.Kind == QueryScopeKind.Meter ? null : MeterLinks.Analysis(meterId, Shown); + } + + return View.Selection.Scope.Kind == QueryScopeKind.Portfolio && series.EnergyTypeId is { } typeId + ? AnalysisLinks.Analysis(QueryScope.ForEnergyType(typeId), null, Shown) + : null; + } + + private string? LinkTextOf(AnalysisSeries series) => + series.MeterId is not null ? S.Analysis_OpenMeter + : series.EnergyTypeId is { } typeId ? Loc.F(S.Analysis_AnalyseScope, Options.TypeName(typeId)) + : null; + + /// What a cost is made of, and for a type its billing basis, for a meter its rule. + private string? CaptionOf(AnalysisCostSeries cost) + { + var current = cost.Current; + if (current.Meter is { } meter) + { + return RuleOf(current); + } + + var parts = PartsOf(current.Total); + if (current.Request.Scope.Kind == CostScopeKind.EnergyType && current.EnergyTypes.FirstOrDefault() is { } type) + { + parts = type.Basis.Display() + (parts is null ? string.Empty : " · " + parts); + } + + return parts; + } + + private string? HrefOf(AnalysisCostSeries cost) => + cost.Current.Request.Scope is { Kind: CostScopeKind.Meter, Id: { } meterId } && View.Selection.Scope.Kind != QueryScopeKind.Meter + ? MeterLinks.Analysis(meterId, Shown) + : null; + + /// "Metered use 1.234,00 € · Standing charges 96,00 € · Manual costs 800,00 €" — only the parts there are. + private string? PartsOf(CostAmount total) + { + List parts = []; + if (total.Usage is { } usage) + { + parts.Add(Loc.F(S.Analysis_PartUsage, Currency.Format(usage))); + } + + if (total.StandingCharge is { } standing) + { + parts.Add(Loc.F(S.Analysis_PartStanding, Currency.Format(standing))); + } + + if (total.Manual is { } manual) + { + parts.Add(Loc.F(S.Analysis_PartManual, Currency.Format(manual))); + } + + if (total.FeedInCredit is { } credit) + { + parts.Add(Loc.F(S.Analysis_PartCredit, Currency.Format(credit))); + } + + return parts.Count > 0 ? string.Join(" · ", parts) : null; + } + + /// How a meter's cost is formed (D-39), or why it has none. + private static string? RuleOf(Infrastructure.Costing.CostAnalysis cost) => cost.Meter switch + { + { Rule: Infrastructure.Costing.MeterCostRule.None } meter => Loc.F(S.Analysis_NotCosted, meter.NotCosted.Display()), + { } meter => Loc.F(S.Analysis_CostRule, meter.Rule.Display()), + _ => null, + }; +} diff --git a/src/App/Components/Pages/AnalysisPage/AnalysisExplanation.razor b/src/App/Components/Pages/AnalysisPage/AnalysisExplanation.razor new file mode 100644 index 0000000..5d57cfd --- /dev/null +++ b/src/App/Components/Pages/AnalysisPage/AnalysisExplanation.razor @@ -0,0 +1,84 @@ +@using MeterVault.App.AnalysisPage +@using MeterVault.Infrastructure.Analysis + +@* Why the Analysis page shows no figures for its address, and what would work (brief §7.4, §4.3): a scope that no longer + exists, more meters than can be compared, or a cost category whose meters cannot be one quantity — named per kind and + unit, with the comparison of each group and the category's cost one click away. Never a silently different view. *@ + + + @switch (Selection.Refusal) + { + case AnalysisPageRefusal.UnknownScope: +
@S.Analysis_RefusalUnknownScope
+
+ + @S.Analysis_ShowAll + +
+ break; + + case AnalysisPageRefusal.TooManyMeters: +
@Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries)
+
+ + @Loc.F(S.Analysis_CompareFirst, AnalysisLimits.MaxSeries) + +
+ break; + + case AnalysisPageRefusal.CategoryMixed: +
@Loc.F(S.Analysis_RefusalCategoryMixed, Selection.ScopeName ?? string.Empty)
+
    + @foreach (var group in Selection.Groups) + { +
  • + @Loc.F(S.Analysis_Group, group.Kind.Display(), group.Unit, string.Join(", ", group.MeterIds.Select(Options.MeterName))) + @if (group.MeterIds.Count <= AnalysisLimits.MaxSeries) + { + + @S.Analysis_CompareGroup + + } +
  • + } +
+
@ShowCost
+ break; + + case AnalysisPageRefusal.CategoryWithoutMeters: +
@Loc.F(S.Analysis_RefusalCategoryWithoutMeters, Selection.ScopeName ?? string.Empty)
+
@ShowCost
+ break; + + case AnalysisPageRefusal.CategoryTooManyMeters: +
@Loc.F(S.Analysis_RefusalCategoryTooManyMeters, Selection.ScopeName ?? string.Empty, CategoryMeterCount, AnalysisLimits.MaxSeries)
+
@ShowCost
+ break; + } +
+ +@code { + [Parameter, EditorRequired] + public AnalysisSelection Selection { get; set; } = null!; + + [Parameter, EditorRequired] + public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty; + + [Parameter, EditorRequired] + public AnalysisQuery Shown { get; set; } = null!; + + /// Shows the chosen alternative (replacing the address). + [Parameter] + public EventCallback OnShow { get; set; } + + private int CategoryMeterCount => Options.Category(Selection.Scope.Id)?.MeterIds.Count ?? 0; + + private RenderFragment ShowCost => __builder => + { + @S.Analysis_ShowCost + }; + + private Task Show(AnalysisQuery query) => OnShow.InvokeAsync(query); +} diff --git a/src/App/Components/Pages/AnalysisPage/AnalysisExplanation.razor.css b/src/App/Components/Pages/AnalysisPage/AnalysisExplanation.razor.css new file mode 100644 index 0000000..f7cbdc6 --- /dev/null +++ b/src/App/Components/Pages/AnalysisPage/AnalysisExplanation.razor.css @@ -0,0 +1,3 @@ +.mv-analysis-refusal__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } +.mv-analysis-refusal__groups { margin: 8px 0 0 0; padding-left: 1.25rem; } +.mv-analysis-refusal__groups li { overflow-wrap: anywhere; } diff --git a/src/App/Components/Pages/AnalysisPage/AnalysisNotes.razor b/src/App/Components/Pages/AnalysisPage/AnalysisNotes.razor new file mode 100644 index 0000000..f248da9 --- /dev/null +++ b/src/App/Components/Pages/AnalysisPage/AnalysisNotes.razor @@ -0,0 +1,61 @@ +@using MeterVault.App.AnalysisPage +@using MeterVault.Core.Analysis.Totals +@using MeterVault.Infrastructure.Analysis + +@* What the figures of the Analysis page are — and are not (brief §6.1, D-22, D-42): a category's meters are shown side by + side and never added; a comparison names the selected meters it does not show for this measure; total use and grid + import are side by side, never summed; an overlapping category is a view on the bill, not a slice of it. *@ + +@if (_notes.Count > 0) +{ +
    + @foreach (var note in _notes) + { +
  • +
  • + } +
+} + +@code { + [Parameter, EditorRequired] + public AnalysisPageView View { get; set; } = null!; + + [Parameter, EditorRequired] + public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty; + + private List _notes = []; + + protected override void OnParametersSet() + { + var selection = View.Selection; + _notes = []; + + if (selection.ShowsMeters && selection.Scope.Kind == QueryScopeKind.Category && View.Series.Count > 1) + { + _notes.Add(S.Analysis_CategorySideBySide); + } + + if (selection.HiddenMeterIds.Count > 0) + { + var names = string.Join(", ", selection.HiddenMeterIds.Select(Options.MeterName)); + _notes.Add(selection.IsCost + ? Loc.F(S.Analysis_NotShownNoCost, names) + : Loc.F(S.Analysis_NotShownForMetric, selection.Metric?.Display() ?? string.Empty, names)); + } + + // Total use and grid import answer different questions: side by side, never added (D-22). + if (!selection.ShowsMeters && View.Kind == AnalysisPageViewKind.Quantity + && View.Series.Any(s => s.Key.Measure == TotalsMeasure.Use) && View.Series.Any(s => s.Key.Measure == TotalsMeasure.GridImport)) + { + _notes.Add(S.Analysis_UseAndGridApart); + } + + if (View.Costs.FirstOrDefault()?.Current.Category is { IsOverlappingView: true }) + { + _notes.Add(S.Analysis_OverlappingView); + } + } +} diff --git a/src/App/Components/Pages/AnalysisPage/AnalysisNotes.razor.css b/src/App/Components/Pages/AnalysisPage/AnalysisNotes.razor.css new file mode 100644 index 0000000..a8f32f9 --- /dev/null +++ b/src/App/Components/Pages/AnalysisPage/AnalysisNotes.razor.css @@ -0,0 +1,3 @@ +.mv-analysis-notes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } +.mv-analysis-notes li { display: flex; align-items: flex-start; gap: 6px; font-size: 0.875rem; color: var(--mud-palette-text-secondary); } +.mv-analysis-notes li span { min-width: 0; overflow-wrap: anywhere; } diff --git a/src/App/Components/Pages/AnalysisPage/AnalysisPageContent.razor b/src/App/Components/Pages/AnalysisPage/AnalysisPageContent.razor new file mode 100644 index 0000000..a948ed6 --- /dev/null +++ b/src/App/Components/Pages/AnalysisPage/AnalysisPageContent.razor @@ -0,0 +1,172 @@ +@using MeterVault.App.AnalysisPage +@using MeterVault.Core.Analysis +@using MeterVault.Core.Analysis.Costing +@using MeterVault.Infrastructure.Analysis +@inject NavigationManager Nav + +@* Everything the Analysis page shows for one committed load (brief §7.4, §4.3): an explanation instead of figures when the + selection cannot be one quantity, the attention items, the empty and pending states, the period figures with their + change, which dates are compared, the chart (a click drills into a bucket, D-51) and the table with its drill-down + links. Everything comes from the one view, so a title never sits above another selection's chart. *@ + +@if (View.Selection.Refusal != AnalysisPageRefusal.None) +{ + +} +else if (View.ReaderRefusal == AnalysisRefusal.TooManySeries) +{ + @Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries) +} +else if (View.ReaderRefusal == AnalysisRefusal.TooManyPoints) +{ + @* The toolbar says which interval would work and offers it. *@ + @S.Analysis_ChooseCoarserBucket +} +else +{ + + + @if (View.NotYetOccurred) + { + + } + else if (View.IsPending) + { + + } + else if (View.HasNoData) + { + + @if (View.Availability is null) + { + @* Nothing at all yet: where data comes from. With older data, the dates and "Go to latest data" say it. *@ + + @S.Analysis_NoDataYetHint + @S.Nav_Import + + } + + } + else + { + + + + + + + + @ChartTitle + + @if (HiddenOverlays) + { + @Loc.F(S.Analysis_ComparisonInTable, AnalysisPageLoader.MaxOverlaidSeries) + } + + + + @S.Analysis_TableTitle + + + + @if (View.Series is [{ Basis: SeriesBasis.Virtual or SeriesBasis.LegacyVirtual } only]) + { + + + + } + } +} + +@code { + [Parameter, EditorRequired] + public AnalysisPageView View { get; set; } = null!; + + [Parameter, EditorRequired] + public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty; + + /// The address as shown when the view was read: drill-downs and links build on it. + [Parameter, EditorRequired] + public AnalysisQuery Shown { get; set; } = null!; + + /// The page defaults, which an address leaves out. + [Parameter, EditorRequired] + public AnalysisDefaults Defaults { get; set; } = null!; + + /// Shows another state (an action of an explanation), replacing the address. + [Parameter] + public EventCallback OnShow { get; set; } + + /// Reads the same state again (analysis being prepared: check whether it is ready). + [Parameter] + public EventCallback OnRefresh { get; set; } + + private AttentionNames _names = new(); + private EventCallback _onBucketClick; + private Func? _drillHref; + + protected override void OnParametersSet() + { + _names = new AttentionNames(Options.MeterNames, Options.TypeNames, Options.Categories.ToDictionary(c => c.Id, c => c.Name)); + + // Buckets are clickable, and the table has its drill column, only when some bucket leads somewhere (D-51): a + // comparison of monthly imports has nothing finer to open. + var drills = View.Plan is { } plan && plan.Buckets.Any(b => DrillHref(b) is not null); + _onBucketClick = drills ? EventCallback.Factory.Create(this, DrillAsync) : default; + _drillHref = drills ? DrillHref : null; + } + + /// "Consumption — Strom", "Cost — All energy types". + private string ChartTitle + { + get + { + var selection = View.Selection; + var what = selection.Metric?.Display() ?? View.Series.FirstOrDefault()?.Kind.Display() ?? string.Empty; + var scope = selection.ScopeName ?? selection.Scope.Kind.Display(); + return what.Length == 0 ? scope : what + " — " + scope; + } + } + + private bool HiddenOverlays => + View.Pairs is not null && (View.Kind == AnalysisPageViewKind.Cost ? View.Costs.Count : View.Series.Count) > AnalysisPageLoader.MaxOverlaidSeries; + + private string? LatestHref => + AnalysisNavigation.LatestData(Shown, View.Availability) is { } latest ? AnalysisNavigation.UriFor(Nav, latest, Defaults) : null; + + /// + /// Where a bucket leads (D-51): the same view over the bucket in the next finer size the data resolves; for one meter + /// whose data is too coarse for that, its records of the bucket; otherwise nowhere. + /// + private string? DrillHref(AnalysisBucket bucket) + { + if (AnalysisNavigation.DrillInto(Shown, bucket, View.Resolution) is { } next) + { + return AnalysisNavigation.UriFor(Nav, next, Defaults); + } + + if (View.Selection.Scope.Kind == QueryScopeKind.Meter && View.Selection.Scope.Id is { } meterId) + { + var (first, last) = AnalysisNavigation.DaysOf(bucket); + return Options.Meter(meterId) is { IsVirtual: true } + ? MeterLinks.Analysis(meterId, PeriodResolver.IsValidCustomRange(first, last) ? Shown.WithCustomRange(first, last) : Shown) + : AnalysisNavigation.NormalizedData(meterId, Shown, bucket); + } + + return null; + } + + private Task DrillAsync(AnalysisBucket bucket) + { + // A drill-down is a new history entry (D-46): Back returns to the coarser view. + if (DrillHref(bucket) is { } href) + { + Nav.NavigateTo(href); + } + + return Task.CompletedTask; + } +} diff --git a/src/App/Components/Pages/AnalysisPage/PickerGroupHeader.razor b/src/App/Components/Pages/AnalysisPage/PickerGroupHeader.razor new file mode 100644 index 0000000..6c3daab --- /dev/null +++ b/src/App/Components/Pages/AnalysisPage/PickerGroupHeader.razor @@ -0,0 +1,20 @@ +@* A group heading inside a MudSelect's list (the meters of one energy type). MudSelect renders its items twice — once in + a hidden "shadow" pass that only registers them, once in the dropdown — and a plain MudListSubheader would show up in + the page from the first pass. This one renders only in the dropdown. *@ + +@if (!HideContent) +{ + @ChildContent +} + +@code { + /// + /// True in MudSelect's hidden registration pass, where nothing may be drawn: MudSelect cascades it by this name to its + /// items (MudSelectItem.HideContent). + /// + [CascadingParameter(Name = "HideContent")] + public bool HideContent { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/src/App/Components/Pages/AnalysisPage/ScopeSelector.razor b/src/App/Components/Pages/AnalysisPage/ScopeSelector.razor new file mode 100644 index 0000000..2b16804 --- /dev/null +++ b/src/App/Components/Pages/AnalysisPage/ScopeSelector.razor @@ -0,0 +1,268 @@ +@using MeterVault.App.AnalysisPage +@using MeterVault.Infrastructure.Analysis +@* What the Analysis page analyses (brief §7.4, D-47): everything, an energy type, a cost category, one meter or up to + six meters side by side — and which measure of it. Meters are picked by name, grouped by energy type, with calculated + and retired meters marked. Nothing here navigates: every choice raises QueryChanged, and the page writes it into its + address (replace), so reload, Back and a shared link restore it. A seventh meter is refused with an explanation, + never dropped silently. *@ + +
+
+ + @foreach (var kind in Kinds) + { + @kind.Display() + } + +
+ + @switch (Selection.Scope.Kind) + { + case QueryScopeKind.EnergyType: +
+ + @foreach (var type in Options.Types) + { + @type.Name + } + +
+ break; + + case QueryScopeKind.Category: +
+ + @foreach (var category in Options.Categories) + { + @category.Name + } + +
+ break; + + case QueryScopeKind.Meter: +
+ + @MeterItems + +
+ break; + + case QueryScopeKind.Meters: +
+ + @MeterItems + +
+ break; + } + + @if (Selection.Metric is { } shown && (Selection.Metrics.Count > 1 || !Selection.Metrics.Contains(shown))) + { +
+ + @* A category quantity that cannot be shown is still what the address asks for: listed, so the way back to + the cost is one choice away. *@ + @foreach (var metric in Selection.Metrics.Contains(shown) ? Selection.Metrics : [shown, .. Selection.Metrics]) + { + @metric.Display() + } + +
+ } +
+ +@code { + /// The meters, energy types and categories to choose from. + [Parameter, EditorRequired] + public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty; + + /// The page's current reading of its address. + [Parameter, EditorRequired] + public AnalysisSelection Selection { get; set; } = null!; + + /// The address as shown (): every choice starts from it. + [Parameter, EditorRequired] + public AnalysisQuery Query { get; set; } = null!; + + /// Raised with the new state; the page writes it into its address (replace). + [Parameter] + public EventCallback QueryChanged { get; set; } + + private static readonly IReadOnlyList Kinds = + [QueryScopeKind.Portfolio, QueryScopeKind.EnergyType, QueryScopeKind.Category, QueryScopeKind.Meter, QueryScopeKind.Meters]; + + private IReadOnlyCollection _meters = []; + private AnalysisQuery? _synced; + private string? _meterHint; + + /// Re-creates the multi-select after a refused pick, so it drops the box it ticked on its own. + private int _pickerGeneration; + + protected override void OnParametersSet() + { + // The multi-select follows the address; a new address also clears the reason for a refused pick. + if (Query != _synced) + { + _synced = Query; + _meters = Selection.Scope.Kind == QueryScopeKind.Meters ? [.. Selection.Scope.MeterIds] : []; + _meterHint = null; + } + } + + private bool IsOffered(QueryScopeKind kind) => kind switch + { + QueryScopeKind.EnergyType => Options.Types.Count > 0, + QueryScopeKind.Category => Options.Categories.Count > 0, + QueryScopeKind.Meter or QueryScopeKind.Meters => Options.Meters.Count > 0, + _ => true, + }; + + /// The meters grouped by energy type, calculated and retired ones marked. + private RenderFragment MeterItems => __builder => + { + foreach (var group in Options.Meters.GroupBy(m => m.EnergyTypeId)) + { + @Options.TypeName(group.Key) + foreach (var meter in group) + { + @MeterLabel(meter.Id) + } + } + }; + + private string MeterLabel(int id) + { + if (Options.Meter(id) is not { } meter) + { + return Options.MeterName(id); + } + + var label = meter.IsVirtual ? Loc.F(S.Analysis_CalculatedMeter, meter.Name) : meter.Name; + return meter.IsRetired ? Loc.F(S.Analysis_RetiredMeter, label) : label; + } + + private string MetersText(IReadOnlyList ids) => + string.Join(", ", ids.Select(text => + int.TryParse(text, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var id) ? MeterLabel(id) : text)); + + private async Task OnKindChanged(QueryScopeKind kind) + { + if (kind == Selection.Scope.Kind) + { + return; + } + + var current = Selection.Scope; + var meterIds = current.MeterIds.Where(id => Options.Meter(id) is not null).ToList(); + var typeId = Selection.EnergyTypeId ?? meterIds.Select(id => Options.Meter(id)!.EnergyTypeId).Cast().FirstOrDefault(); + QueryScope? next = kind switch + { + QueryScopeKind.Portfolio => QueryScope.Portfolio, + QueryScopeKind.EnergyType => (typeId ?? Options.Types.FirstOrDefault()?.Id) is { } type ? QueryScope.ForEnergyType(type) : null, + QueryScopeKind.Category => Options.Categories.FirstOrDefault() is { } category ? QueryScope.ForCategory(category.Id) : null, + QueryScopeKind.Meter => FirstMeter(meterIds, typeId) is { } meter ? QueryScope.ForMeter(meter) : null, + _ => MetersFor(meterIds, typeId) is { Count: > 0 } ids ? QueryScope.ForMeters(ids) : null, + }; + + if (next is not null) + { + await ChangeScope(next); + } + } + + /// The meter a switch to one meter starts with: the one shown, else the type's first, else the first. + private int? FirstMeter(IReadOnlyList meterIds, int? typeId) => + meterIds.Count > 0 ? meterIds[0] + : (Options.Meters.FirstOrDefault(m => m.EnergyTypeId == typeId && !m.IsVirtual && !m.IsRetired) + ?? Options.Meters.FirstOrDefault(m => !m.IsVirtual && !m.IsRetired) + ?? Options.Meters.FirstOrDefault())?.Id; + + /// + /// The meters a switch to a comparison starts with: those shown, else the energy type's meters of the measure shown + /// (when they fit), else one meter to add others to. + /// + private List MetersFor(IReadOnlyList meterIds, int? typeId) + { + if (meterIds.Count > 0) + { + return [.. meterIds]; + } + + var ofType = Options.Meters + .Where(m => m.EnergyTypeId == typeId && !m.IsRetired && (Selection.IsCost ? m.IsCostable : m.Metric == Selection.Metric)) + .Select(m => m.Id) + .ToList(); + if (ofType.Count is > 0 and <= AnalysisLimits.MaxSeries) + { + return ofType; + } + + return FirstMeter([], typeId) is { } first ? [first] : []; + } + + private async Task ChangeScope(QueryScope scope) + { + if (scope.Equals(Selection.Scope)) + { + return; + } + + // The measure goes along when the new scope has it; otherwise the new scope's natural one. + var next = Query.WithScope(scope); + if (next.Metric is { } metric && !AnalysisSelection.Resolve(next, Options).Metrics.Contains(metric)) + { + next = next.WithMetric(null); + } + + await QueryChanged.InvokeAsync(next); + } + + private async Task OnMetersChanged(IReadOnlyCollection values) + { + var chosen = values.ToList(); + if (chosen.Count > AnalysisLimits.MaxSeries) + { + // Refused with the reason; the selection stays as it was. + _meterHint = Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries); + _meters = [.. Selection.Scope.MeterIds]; + _pickerGeneration++; + return; + } + + if (chosen.Count == 0) + { + _meterHint = S.Analysis_AtLeastOneMeter; + _meters = [.. Selection.Scope.MeterIds]; + _pickerGeneration++; + return; + } + + _meterHint = null; + _meters = chosen; + + // Keep the order in which meters were picked: the ones already shown first. + var ordered = Selection.Scope.MeterIds.Where(chosen.Contains).Concat(chosen.Where(id => !Selection.Scope.MeterIds.Contains(id))).ToList(); + await ChangeScope(QueryScope.ForMeters(ordered)); + } + + private async Task OnMetricChanged(AnalysisMetric metric) + { + if (metric != Selection.Metric) + { + await QueryChanged.InvokeAsync(Query.WithMetric(metric == Selection.NaturalMetric ? null : metric)); + } + } +} diff --git a/src/App/Components/Pages/AnalysisPage/ScopeSelector.razor.css b/src/App/Components/Pages/AnalysisPage/ScopeSelector.razor.css new file mode 100644 index 0000000..85b2ada --- /dev/null +++ b/src/App/Components/Pages/AnalysisPage/ScopeSelector.razor.css @@ -0,0 +1,7 @@ +/* The "what" row of the Analysis page: wraps like the period toolbar, one field per row on a phone. */ +.mv-scope { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px 12px; } +.mv-scope__field { flex: 1 1 180px; min-width: 160px; max-width: 280px; } +.mv-scope__field--wide { flex: 2 1 280px; max-width: 560px; } +@media (max-width: 599.98px) { + .mv-scope__field, .mv-scope__field--wide { flex: 1 1 100%; min-width: 0; max-width: none; } +} diff --git a/src/App/Components/Pages/Consumables.razor b/src/App/Components/Pages/Consumables.razor index bea87c3..54574f9 100644 --- a/src/App/Components/Pages/Consumables.razor +++ b/src/App/Components/Pages/Consumables.razor @@ -1,215 +1,126 @@ @page "/consumables" +@using MeterVault.App.Components.Pages.Specialized +@using MeterVault.Core.Analysis +@implements IDisposable +@inject NavigationManager Nav +@inject InstanceClock Clock @inject ConsumableService ConsumablesSvc -@inject Microsoft.Extensions.Options.IOptions Options -@using MudBlazor +@inject ILogger Logger -MeterVault — @S.Consumables_PageTitle +@* Tanks & consumables (brief §7.5, D-54): the shared header, toolbar and missing-data semantics around every tank's + specialised measures. Each tank keeps its state now — the last dipstick as measured, the contents estimated from it, + the forecast as a projection — apart from the selected period, which has its own usage, deliveries, burner runtime, + cost and, for a period that is over, the contents at its end. *@ -
- @S.Nav_Consumables - - @S.Common_RangeLast12Months - @S.Common_RangeLast24Months - @S.Common_RangeLast5Years - @S.Common_RangeAllTime - -
+ + + + + -@* A consumable meter without a tank has no capacity or calibration, so it cannot be summarised — - but it must not just be missing from the page, with nothing saying where it went. *@ -@foreach (var meter in _unconfigured) +@if (_query is not null) { - - @Loc.F(S.Consumables_TankNotConfigured, meter.Name) - @S.MeterDetail_SetUpTank - + } -@if (_items is null) -{ - -} -else if (_items.Count == 0 && _unconfigured.Count == 0) -{ - - @S.Consumables_NoMetersLead @MeterMode.ConsumableBalance.Display() @S.Consumables_NoMetersTail - @S.Nav_Meters@S.Consumables_NoMetersOrImport - @S.Nav_Import. - -} -else -{ - @foreach (var item in _items) + + @* A consumable meter without a tank has no capacity or calibration, so it cannot be summarised — but it must not just + be missing from the page, with nothing saying where it went. *@ + @foreach (var meter in view.Analysis.Unconfigured) { - - @* The actions for a tank are the ones done standing next to it, so they sit on its card. *@ -
- @item.Name - - @S.MeterDetail_RecordTankLevel - @S.Consumables_RecordDelivery -
- - - @S.Consumables_TankLevel - - @(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—") - - - - @Loc.F(S.Consumables_FillOfCapacity, Format.Number(item.FillFraction * 100, 0), Format.Number(item.Capacity, 0), item.Unit) - @if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm") - { - · @Format.Number(cm, 0) cm - } - @if (item.LevelAsOf is { } asOf) - { - · @Loc.F(S.Consumables_AsOf, Local(asOf).ToString("yyyy-MM-dd")) - } - - - - - - - @S.Consumables_UsedRange - @Format.Number(item.ConsumptionInRange, 0) @item.Unit - - - @S.Consumables_BurnerRuntime - @(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—") - - - @S.Consumables_EffectiveRate - - @if (item.FixedRate is { } fr) - { - @Format.Number(fr, 2) @item.Unit/h - } - else if (item.EffectiveRate is { } er) - { - @Format.Number(er, 2) @item.Unit/h - } - else - { - - } - - @item.RateMode.Display() - - - @S.Common_CostRange - @Format.Euro(item.CostInRange) - - - @S.Consumables_ForecastEmpty - - @(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—") - @if (item.AveragePerDay is { } apd) - { - - @Loc.F(S.Consumables_PerDay, Format.Number(apd, 1), item.Unit) - - } - - - - - - - @S.Consumables_ConsumptionByMonth - - - - - @Loc.F(S.Consumables_DeliveriesCount, item.Deliveries.Count) - @if (item.Deliveries.Count == 0) - { - @S.Consumables_NoDeliveries - } - else - { -
- - - @S.Common_Date@S.Common_Amount - - - @foreach (var delivery in item.Deliveries) - { - - @Local(delivery.Time).ToString("yyyy-MM-dd") - @Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit) - - } - - -
- } -
-
-
+ + @Loc.F(S.Consumables_TankNotConfigured, meter.Name) + @S.Consumables_SetUpTank + } -} + + @if (view.Analysis.Tanks.Count == 0 && view.Analysis.Unconfigured.Count == 0) + { +
+
+ } + else if (!view.Analysis.IsRefused) + { + @foreach (var tank in view.Tanks) + { + + } + } +
@code { - private int _months = 60; - private bool _loading; - private IReadOnlyList? _items; - private IReadOnlyList _unconfigured = []; - private TimeZoneInfo _tz = TimeZoneInfo.Utc; + private static readonly AnalysisDefaults Defaults = AnalysisDefaults.History; - protected override Task OnInitializedAsync() + private readonly LoadSequencer _loads = new(); + private readonly LoadState _state = new(); + private AnalysisQuery? _query; + + /// One committed result: the query it answers, the read model, every tank's series and the export link. + private sealed record ConsumablesPageView(AnalysisQuery Query, ConsumableAnalysis Analysis, IReadOnlyList Tanks, string? ExportHref); + + protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged; + + protected override Task OnParametersSetAsync() => ReloadIfChangedAsync(); + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(ReloadIfChangedAsync); + + private async Task ReloadIfChangedAsync() { - _tz = LocalTimeEntry.Resolve(Options.Value.TimeZone); - return LoadAsync(); - } - - private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz); - - private async Task OnRangeChanged(int months) - { - _months = months; - await LoadAsync(); - } - - private async Task LoadAsync() - { - if (_loading) + var query = AnalysisQuery.Parse(Nav.Uri, Defaults); + if (query == _query) { return; } - _loading = true; - _items = null; - try - { - var asOf = DateOnly.FromDateTime(DateTime.UtcNow); - var from = asOf.AddMonths(-_months); - _unconfigured = await ConsumablesSvc.GetUnconfiguredAsync(); - _items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1)); - } - finally - { - _loading = false; - } + _query = query; + await LoadAsync(query); + StateHasChanged(); } - private static IReadOnlyList ChartFor(ConsumableSummary item) + private Task RetryAsync() => _query is null ? Task.CompletedTask : LoadAsync(_query); + + private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token => { - var points = item.Months - .Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Consumption)) - .ToList(); - return [new SeriesChart.SeriesDef(Loc.F(S.Consumables_UnitUsed, item.Unit), ApexCharts.SeriesType.Bar, points)]; + // "Now" is read once per load (D-01); "all" spans what the tanks have data for (D-19). + var now = Clock.Now; + var availability = query.Period == PeriodPreset.AllHistory ? await ConsumablesSvc.GetAvailabilityAsync(now, token) : null; + var period = query.Resolve(now, ConsumablesSvc.Zone, availability); + var analysis = await ConsumablesSvc.GetAsync(new ConsumableRequest(period) { Bucket = query.Bucket, Comparison = query.Comparison }, token); + var tanks = analysis.Tanks.Select(t => TankView.Build(t, analysis.Quantities, query, analysis.Currency)).ToList(); + + // The CSV export of what the tables show: every tank's usage (D-55), within the chart's series limit. + var ids = analysis.Tanks.Select(t => t.MeterId).ToList(); + var export = ids.Count is > 0 and <= MeterVault.Infrastructure.Analysis.AnalysisLimits.MaxSeries + ? AnalysisLinks.Export(query.WithScope(QueryScope.ForMeters(ids)).WithMetric(AnalysisMetric.Consumption)) + : null; + return new ConsumablesPageView(query, analysis, tanks, export); + }, Logger); + + private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults); + + /// The reader's problems about one tank and the burners it feeds. + private static IReadOnlyList ProblemsOf(ConsumableAnalysis analysis, TankAnalysis tank) + { + var ids = tank.Runtime.Select(r => r.MeterId).OfType().Append(tank.MeterId).ToHashSet(); + var problems = (analysis.Quantities?.Problems ?? []).Concat(tank.Cost?.QuantityProblems ?? []); + return [.. problems.Where(p => p.MeterId is { } id ? ids.Contains(id) : p.MeterIds.Any(ids.Contains))]; } - private static Color FillColor(double fraction) => fraction switch + public void Dispose() { - < 0.15 => Color.Error, - < 0.30 => Color.Warning, - _ => Color.Success, - }; + Nav.LocationChanged -= OnLocationChanged; + _loads.Dispose(); + } } diff --git a/src/App/Components/Pages/Dashboard.razor b/src/App/Components/Pages/Dashboard.razor index 5d9c2b4..8951cef 100644 --- a/src/App/Components/Pages/Dashboard.razor +++ b/src/App/Components/Pages/Dashboard.razor @@ -1,128 +1,251 @@ @page "/" +@using MeterVault.App.Components.Pages.Overview +@using MeterVault.Core.Analysis +@using MeterVault.Core.Analysis.Coverage +@using Microsoft.AspNetCore.Components.Routing +@inject NavigationManager Nav +@inject InstanceClock Clock +@inject AnalysisPeriods Periods @inject DashboardService Dash +@inject ILogger Logger +@implements IDisposable -MeterVault — @S.Nav_Overview +@* The Overview (brief §7.1): what happened in the chosen period, what changed, and where to look — the portfolio's + quantities per energy type in their own units and the bill for the same resolved period and buckets, with the + comparison over the coverage both periods share. The toolbar's range applies to every panel; a period without data + says so and offers the latest data as its own period instead of silently showing it (D-19). The update banner sits in + the header, apart from the analytical status. *@ -@S.Nav_Overview + + + - +
+ -@if (_summary is null) -{ - -} -else -{ - - - - @S.Dashboard_ThisMonth - @Format.Euro(_summary.Month.Current) - - - - - - @S.Common_ThisYear - @Format.Euro(_summary.Year.Current) - - - - - - @S.Dashboard_LatestMonthWithData - @Format.Euro(_summary.LatestMonthCost) - - + + @if (!view.Data.IsRefused) + { + + } - - - @S.Dashboard_WhatCostsMost - @if (_breakdown is { Count: > 0 }) + @if (view.Data.IsRefused) + { + @* The toolbar explains the refused bucket size and offers a coarser one; nothing was read. *@ + } + else if (view.Data.IsPending) + { + + + } + else if (view.Data.NotYetOccurred || view.Data.HasNoData) + { + + @if (view.Data.Availability is null) { - - - - @foreach (var slice in _breakdown) - { - - @slice.Name - @Format.Euro(slice.Cost) - - } - - - } - else - { - @* Names the one step that is missing, in setup order, rather than every admin page. *@ - - @switch (_setup?.FirstGap) - { - case CostSetupGap.NoMeters: - @S.Dashboard_SetupNoMeters @S.Nav_Meters · @S.Nav_Import - break; - case CostSetupGap.NoCategories: - @S.Dashboard_SetupNoCategories @S.Nav_CostCategories - break; - case CostSetupGap.NoMembers: - @S.Dashboard_SetupNoMembers @S.Nav_Meters · @S.Nav_CostCategories - break; - case CostSetupGap.NoTariffs: - @S.Dashboard_SetupNoTariffs @S.Nav_Tariffs - break; - default: - @S.Dashboard_SetupNoCostsThisYear - break; - } + + @S.Dashboard_SetupNoMeters + @S.Nav_Meters · + @S.Nav_Import } - - - - - - @S.Dashboard_WhatChanged - - - @S.Dashboard_ColCategory@S.Dashboard_ColCurrent@S.Dashboard_ColPreviousΔ - - - @foreach (var row in _difference) + + @if (view.Data.Types.Count > 0) + { + + } + + } + else + { + + +
+ + @if (HasAttention(view)) { - - @row.Name - @Format.Euro(row.Current) - @Format.Euro(row.Previous) - - @Format.DirectionIcon(Math.Sign(row.Delta)) @Format.Euro(Math.Abs(row.Delta)) - - + + @if (view.Data.Setup?.FirstGap is CostSetupGap.NoMeters or CostSetupGap.NoTariffs) + { +
+

@S.Overview_SetupTitle

+ + @if (view.Data.Setup.FirstGap == CostSetupGap.NoMeters) + { + @S.Dashboard_SetupNoMeters + @S.Nav_Meters · + @S.Nav_Import + } + else + { + @S.Dashboard_SetupNoTariffs + @S.Nav_Tariffs + } + +
+ } + +
} - - - - - -} +
+
+ @foreach (var type in view.Data.Types) + { + + + + } +
+ + @if (TypesWithoutMeters(view) is { Count: > 0 } empty) + { + + } + + @* The ranges compared; the matched stretch only when less than the whole periods is compared (D-07). *@ + + + + + + + + + + + + + + } +
+
@code { - private DashboardSummary? _summary; - private IReadOnlyList _breakdown = []; - private IReadOnlyList _difference = []; - private CostSetup? _setup; + private static readonly AnalysisDefaults Defaults = AnalysisDefaults.Overview; - protected override async Task OnInitializedAsync() + private readonly LoadSequencer _loads = new(); + private readonly LoadState _state = new(); + private AnalysisQuery? _query; + private AnalysisQuery? _requested; + private string? _chartKey; + + private string? ExportHref { - var asOf = DateOnly.FromDateTime(DateTime.UtcNow); - _summary = await Dash.GetSummaryAsync(asOf); - - var yearStart = new DateOnly(asOf.Year, 1, 1); - _breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1)); - if (_breakdown.Count == 0) + get { - _setup = await Dash.GetCostSetupAsync(); + if (_query is null) + { + return null; + } + + var option = _state.Value?.Option(_chartKey); + var scoped = _query.WithScope(option?.Scope ?? QueryScope.Portfolio).WithMetric(option?.Metric ?? AnalysisMetric.Cost); + return AnalysisLinks.Export(scoped); } - _difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1)); + } + + protected override void OnInitialized() + { + _query = AnalysisQuery.Parse(Nav.Uri, Defaults); + _chartKey = OverviewView.ChartKeyOf(Nav.Uri); + Nav.LocationChanged += OnLocationChanged; + } + + protected override Task OnParametersSetAsync() => ReloadIfChangedAsync(); + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () => + { + _chartKey = OverviewView.ChartKeyOf(Nav.Uri); + StateHasChanged(); + await ReloadIfChangedAsync(); + StateHasChanged(); + }); + + /// Loads when the analysis state changed; a chart selection alone is not a new analysis (D-46). + private async Task ReloadIfChangedAsync() + { + var query = AnalysisQuery.Parse(Nav.Uri, Defaults); + if (query == _requested) + { + return; + } + + _requested = query; + _query = query; + await LoadAsync(query); + } + + private Task RetryAsync() => LoadAsync(_query!); + + private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token => + { + // "Now" once per load (D-01); the whole page answers this one period. + var now = Clock.Now; + var period = query.Period == PeriodPreset.AllHistory + ? query.Resolve(now, Periods.Zone, await AllHistoryAsync(query, now, token)) + : await Periods.ResolveAsync(query, now, token); + var data = await Dash.GetOverviewAsync(period, query.Bucket, query.Comparison, token); + return OverviewView.Build(query, data); + }, Logger); + + /// All history on the Overview spans both what the meters measured and the bill (manual costs included, D-19). + private async Task AllHistoryAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken token) + { + var quantities = await Periods.AvailabilityAsync(query.WithMetric(null), now, token); + var costs = await Periods.AvailabilityAsync(query.WithMetric(AnalysisMetric.Cost), now, token); + return AvailableRange.Union([quantities, costs], Periods.Zone); + } + + private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults); + + /// The chart selection goes into the address (replace): a reload or a shared link shows the same (D-46). + private void OnChartKeyChanged(string key) + { + _chartKey = key == OverviewView.CostKey ? null : key; + Nav.NavigateTo(Nav.GetUriWithQueryParameter(OverviewView.ChartParameter, _chartKey), replace: true); + } + + /// A clicked bucket opens on the Overview itself, one size finer (D-51) — a drill-down pushes. + private void Drill((AnalysisBucket Bucket, ResolutionClass? Resolution) click) + { + if (_query is not null && AnalysisNavigation.DrillInto(_query, click.Bucket, click.Resolution) is { } next) + { + Nav.NavigateTo(AnalysisNavigation.UriFor(Nav, next, Defaults)); + } + } + + /// + /// "Go to latest data": a period of the same kind ending with the latest data — for month to date the latest month + /// with data — opened as its own period on the Overview (brief §4.3). Nothing moves by itself. + /// + private static string? LatestHref(OverviewView view) => + AnalysisNavigation.LatestData(view.Query, view.Data.Availability) is { } target ? AnalysisLinks.Overview(target) : null; + + private static bool HasAttention(OverviewView view) => + view.AttentionCount > 0 || view.Data.Setup?.FirstGap is CostSetupGap.NoMeters or CostSetupGap.NoTariffs; + + private static List TypesWithoutMeters(OverviewView view) => [.. view.Data.EnergyTypes.Where(t => !t.HasMeters)]; + + public void Dispose() + { + Nav.LocationChanged -= OnLocationChanged; + _loads.Dispose(); } } diff --git a/src/App/Components/Pages/Dashboard.razor.css b/src/App/Components/Pages/Dashboard.razor.css new file mode 100644 index 0000000..42de8be --- /dev/null +++ b/src/App/Components/Pages/Dashboard.razor.css @@ -0,0 +1,120 @@ +/* The Overview's panels (Components/Pages/Overview): one look for their heads and footers. Palette variables only, so + light and dark mode both work; everything wraps down to 360px and wide tables scroll inside their own box. */ + +.mv-ov ::deep .mv-ov-panel { + min-width: 0; +} + +.mv-ov ::deep .mv-ov-panel__head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; + margin-bottom: 8px; +} + +.mv-ov ::deep .mv-ov-panel__title { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +.mv-ov ::deep .mv-ov-panel__controls { + flex: 0 1 320px; + min-width: 200px; +} + +.mv-ov ::deep .mv-ov-panel__foot { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 4px 16px; + margin-top: 4px; +} + +.mv-ov ::deep .mv-ov-typelinks { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 12px; + font-size: 0.875rem; +} + +/* The first column: the period's cost, and under it what needs attention, together as tall as the type cards. */ +.mv-ov ::deep .mv-ov-lead { + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; +} + +.mv-ov ::deep .mv-ov-lead .mv-metric { + height: auto; +} + +.mv-ov ::deep .mv-ov-lead .mv-metric:only-child { + height: 100%; +} + +.mv-ov ::deep .mv-ov-attention { + flex: 1 1 auto; + min-width: 0; +} + +.mv-ov ::deep .mv-ov-card { + height: 100%; +} + +/* In the narrow first column: the icon beside the words, the action under them. */ +.mv-ov ::deep .mv-ov-attention .mv-attention__item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + column-gap: 8px; + align-items: start; +} + +.mv-ov ::deep .mv-ov-attention .mv-attention__icon { + margin-top: 2px; +} + +.mv-ov ::deep .mv-ov-attention .mv-attention__action { + grid-column: 2; +} + +/* The change table and the composition: names and status words wrap, amounts do not. The tone classes win over the + table cell's own text colour; the total row is set apart. */ +.mv-ov ::deep .mv-ov-amount { + white-space: nowrap; +} + +.mv-ov ::deep .mv-ov-changes th.mv-num, +.mv-ov ::deep .mv-ov-changes td.mv-num, +.mv-ov ::deep .mv-ov-composition th.mv-num, +.mv-ov ::deep .mv-ov-composition td.mv-num { + text-align: right; +} + +.mv-ov ::deep .mv-ov-changes td.mv-change-good { color: var(--mud-palette-success); } +.mv-ov ::deep .mv-ov-changes td.mv-change-bad { color: var(--mud-palette-error); } +.mv-ov ::deep .mv-ov-changes td.mv-change-neutral { color: var(--mud-palette-text-secondary); } + +.mv-ov ::deep .mv-ov-total > td { + font-weight: 600; + border-top: 2px solid var(--mud-palette-lines-default); +} + +@media (min-width: 600px) { + .mv-ov ::deep .mv-ov-changes td.mv-num, + .mv-ov ::deep .mv-ov-composition td.mv-num { + white-space: nowrap; + } +} + +@media (max-width: 599.98px) { + .mv-ov ::deep .mv-ov-changes .mud-table-row + .mud-table-row > td:first-child, + .mv-ov ::deep .mv-ov-composition .mud-table-row + .mud-table-row > td:first-child { + border-top: 1px solid var(--mud-palette-lines-default); + } +} diff --git a/src/App/Components/Pages/Energy/EnergyFlowTab.razor b/src/App/Components/Pages/Energy/EnergyFlowTab.razor new file mode 100644 index 0000000..4b71cc1 --- /dev/null +++ b/src/App/Components/Pages/Energy/EnergyFlowTab.razor @@ -0,0 +1,108 @@ +@using MeterVault.App.Energy +@using MeterVault.Core.Analysis + +@* The energy type's Flow (brief §7.3, D-30): the Sankey as a topology tool, fed with the canonical period totals — a + virtual sum drawn from its calculation inputs (marked calculated), a meter below several others split in proportion + (marked estimated) — and its table equivalent: every ribbon with what it is, every meter with its signed value or its + status in words, and why a meter is not drawn. "Manage connections" edits the topology. No topology never stops the + analysis: the other tabs do not depend on it. *@ + +@if (Analysis.Flow is { } flow) +{ +
+
+
+ @S.EnergyView_Flow + @Loc.F(S.EnergyView_FlowCaption, flow.Unit) +
+ @S.EnergyView_ManageConnections +
+ + @if (!flow.HasChain) + { + @S.EnergyView_NoConnections + } + else + { + +
    +
  • @S.EnergyView_LegendMeasured
  • +
  • @S.EnergyView_LegendCalculated
  • +
  • @S.EnergyView_LegendEstimated
  • +
  • @S.EnergyView_LegendOther
  • +
+ } + + @S.EnergyView_FlowTable + + @if (flow.Links.Count > 0) + { + +
+ + @S.EnergyView_Connections + + + @S.EnergyView_ColFrom + @S.EnergyView_ColTo + @S.Common_Amount + @S.Common_Type + + + + @foreach (var link in flow.Links.OrderBy(l => FlowText.NodeName(flow, l.From), StringComparer.CurrentCultureIgnoreCase).ThenByDescending(l => l.Value)) + { + + @FlowText.NodeName(flow, link.From) + @FlowText.NodeName(flow, link.To) + @Format.Quantity(link.Value, flow.Unit) + @FlowText.EdgeKind(flow, link) + + } + + +
+
+ } + +
+ + @S.Common_Meters + + + @S.Common_Meter + @S.Meters_ColValue + @S.EnergyView_ColDiagram + + + + @foreach (var meter in flow.Meters.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)) + { + + @meter.Name + @FlowText.MeterValue(meter) + @(FlowText.NotDrawnReason(flow, meter) ?? S.EnergyView_InDiagram) + + } + + +
+
+
+
+} + +@code { + /// The page's committed value. + [Parameter, EditorRequired] + public EnergyAnalysis Analysis { get; set; } = null!; + + /// The page's analysis state (meter links carry its period). + [Parameter, EditorRequired] + public AnalysisQuery Query { get; set; } = null!; + + /// Opens the page's "Manage connections" dialog. + [Parameter] + public EventCallback ManageConnections { get; set; } +} diff --git a/src/App/Components/Pages/Energy/EnergyFlowTab.razor.css b/src/App/Components/Pages/Energy/EnergyFlowTab.razor.css new file mode 100644 index 0000000..6ceff81 --- /dev/null +++ b/src/App/Components/Pages/Energy/EnergyFlowTab.razor.css @@ -0,0 +1,62 @@ +.mv-energy-flow__head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 8px 16px; + margin-bottom: 12px; +} + +.mv-energy-flow__intro { + flex: 1 1 320px; + min-width: 0; +} + +.mv-energy-flow__legend { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin: 8px 0 0 0; + padding: 0; + font-size: 0.8125rem; + color: var(--mud-palette-text-secondary); +} + +.mv-energy-flow__legend li { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.mv-swatch { + display: inline-block; + width: 22px; + height: 10px; + border-radius: 2px; + flex: none; +} + +.mv-swatch--measured { + background: var(--mud-palette-text-secondary); + opacity: 0.6; +} + +.mv-swatch--calculated { + border: 1.5px dashed var(--mud-palette-text-secondary); + background: transparent; +} + +.mv-swatch--estimated { + border: 1.5px dotted var(--mud-palette-text-primary); + background: var(--mud-palette-action-disabled-background); +} + +.mv-swatch--other { + background: #78909C; +} + +.mv-energy-flow ::deep td.mv-energy-flow__kind { + white-space: normal; + min-width: 24ch; +} diff --git a/src/App/Components/Pages/Energy/EnergyHistoryTab.razor b/src/App/Components/Pages/Energy/EnergyHistoryTab.razor new file mode 100644 index 0000000..b120f0a --- /dev/null +++ b/src/App/Components/Pages/Energy/EnergyHistoryTab.razor @@ -0,0 +1,208 @@ +@using MeterVault.App.Energy +@using MeterVault.Core.Analysis +@using MeterVault.Infrastructure.Analysis +@inject NavigationManager Nav + +@* The energy type's History (brief §7.3): the shared chart and table for the chosen metric, over the page's period and + interval, compared with the chosen period (a calendar year for a year). "Total" charts the type's measures — never a + breakdown on top of its parent or a calculated view on top of its sources (D-22); "Individual meters" charts the + meters side by side and says how each one counts, so their bars are not read as adding up. Signed values stay signed. + A bucket opens its finer detail (D-51). *@ + +
+
+ + + + + + @(_view?.IsIndividual == true ? S.EnergyView_ViewMetersHelp : S.EnergyView_ViewTotalHelp) + +
+ + @if (_view is null || _quantities is null) + { + @* Nothing read yet: the page shows its own loading state. *@ + } + else if (_view.IsEmpty) + { + @EmptyText() + } + else if (_view.Main is { IsPending: true }) + { + + } + else if (_quantities.NotYetOccurred || NoData()) + { + + } + else + { + + + + @if (_view.IsIndividual) + { + @if (_view.Hidden > 0) + { + + @Loc.F(S.EnergyView_MoreMeters, _view.Shown.Count, _view.Shown.Count + _view.Hidden) + @S.Nav_Analysis + + } + @if (_view.Memberships.Count > 0) + { +
+ @S.EnergyView_HowCounted +
    + @foreach (var (series, membership) in _view.Memberships) + { +
  • +
  • + } +
+
+ } + } + else if (_view.Metric == AnalysisMetric.Cost && Analysis.Cost is { } cost) + { + + } + +
+ +
+ } +
+ +@code { + /// The page's committed value. + [Parameter, EditorRequired] + public EnergyAnalysis Analysis { get; set; } = null!; + + /// The page's analysis state. + [Parameter, EditorRequired] + public AnalysisQuery Query { get; set; } = null!; + + /// The page's defaults (drill-downs and "latest data" keep the page's other keys). + [Parameter, EditorRequired] + public AnalysisDefaults Defaults { get; set; } = null!; + + /// The metric shown (). + [Parameter] + public AnalysisMetric Metric { get; set; } + + /// The view key ( or ). + [Parameter] + public string View { get; set; } = EnergyPageKeys.ViewTotal; + + /// The view was switched; the page writes it into its address. + [Parameter] + public EventCallback ViewChanged { get; set; } + + /// Loads again (analysis being prepared). + [Parameter] + public EventCallback OnRefresh { get; set; } + + private readonly string _countsId = "mv-counts-" + Guid.NewGuid().ToString("N")[..8]; + private object? _builtFrom; + private EnergyHistoryView? _view; + private AnalysisResult? _quantities; + private IReadOnlyList _buckets = []; + private IReadOnlyList? _pairs; + private string _title = string.Empty; + private EventCallback _onBucketClick; + private Func? _drillHref; + + protected override void OnParametersSet() + { + // Rebuilt only for a new value, metric, view or comparison: the chart re-keys on a new list. + var source = (Analysis, Metric, View, Query.Comparison); + if (Equals(_builtFrom, source)) + { + return; + } + + _builtFrom = source; + _quantities = Analysis.Quantities; + _view = EnergyHistoryView.Build(Analysis, Metric, View == EnergyPageKeys.ViewMeters, Query.Comparison); + if (_view.Metric == AnalysisMetric.Cost && Analysis.Cost is { } cost) + { + _buckets = cost.Plan.Buckets; + _pairs = Analysis.CostComparison?.Pairs is { Count: > 0 } pairs ? pairs : null; + } + else + { + _buckets = _quantities?.Plan.Buckets ?? []; + _pairs = _quantities?.Comparison?.Buckets; + } + + var typeName = Analysis.Type?.Name ?? string.Empty; + _title = Loc.F(S.EnergyView_ChartTitle, Metric.Display(), typeName); + + // Buckets are clickable, and the table has its drill column, only when some bucket leads somewhere (D-51): monthly + // data has no days to open, and a click that does nothing is a dead end. + var drills = _buckets.Any(b => DrillHref(b) is not null); + _onBucketClick = drills ? EventCallback.Factory.Create(this, DrillAsync) : default; + _drillHref = drills ? DrillHref : null; + } + + /// The buckets are finer than the data: open the interval that shows it (replacing the address, D-46). + private void UseBucket(BucketSize size) => AnalysisNavigation.Replace(Nav, Query.WithBucket(size), Defaults); + + private async Task OnViewChangedAsync(string? view) => + await ViewChanged.InvokeAsync(EnergyPageKeys.ResolveView(view)); + + private string EmptyText() + { + if (_view!.Metric == AnalysisMetric.Cost) + { + return S.EnergyView_CostPerMeterNote; + } + + return _view.IsIndividual + ? Loc.F(S.EnergyView_NoMetersForMetric, _view.Metric.Display()) + : Loc.F(S.EnergyView_NoTotalForMetric, _view.Metric.Display()); + } + + private bool NoData() + { + if (_view!.Metric == AnalysisMetric.Cost) + { + return false; + } + + var shown = _view.IsIndividual ? _view.Shown : EnergyMetrics.MeasuresOf(_quantities, _view.Metric); + return _view.HasNoData(shown); + } + + private MeterVault.Core.Analysis.Coverage.AvailableRange? Availability() => + _view?.Main?.Availability ?? _quantities?.Availability.Quantity; + + private ComparisonResolution? ComparisonResolution() => + _view?.Metric == AnalysisMetric.Cost ? Analysis.CostComparison?.Resolution : _quantities?.Comparison?.Resolution; + + private string? LatestHref() => + AnalysisNavigation.LatestData(Query, Availability()) is { } latest ? AnalysisNavigation.UriFor(Nav, latest, Defaults) : null; + + private string? DrillHref(AnalysisBucket bucket) => + AnalysisNavigation.DrillInto(Query, bucket, _view?.Coarsest) is { } next ? AnalysisNavigation.UriFor(Nav, next, Defaults) : null; + + /// A chart bucket opens its finer detail (D-51), pushing a history entry so Back returns here. + private void DrillAsync(AnalysisBucket bucket) + { + if (DrillHref(bucket) is { } href) + { + Nav.NavigateTo(href); + } + } +} diff --git a/src/App/Components/Pages/Energy/EnergyHistoryTab.razor.css b/src/App/Components/Pages/Energy/EnergyHistoryTab.razor.css new file mode 100644 index 0000000..35a3f90 --- /dev/null +++ b/src/App/Components/Pages/Energy/EnergyHistoryTab.razor.css @@ -0,0 +1,32 @@ +.mv-energy-history__views { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 12px; + margin-bottom: 12px; +} + +.mv-energy-history__views ::deep .mv-energy-history__toggle { + flex: none; +} + +.mv-energy-history__counts { + margin-top: 12px; +} + +.mv-energy-history__counts ul { + list-style: none; + margin: 4px 0 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.875rem; +} + +.mv-energy-history__counts li { + display: flex; + align-items: flex-start; + gap: 6px; + overflow-wrap: anywhere; +} diff --git a/src/App/Components/Pages/Energy/EnergyOverviewTab.razor b/src/App/Components/Pages/Energy/EnergyOverviewTab.razor new file mode 100644 index 0000000..e8234b4 --- /dev/null +++ b/src/App/Components/Pages/Energy/EnergyOverviewTab.razor @@ -0,0 +1,305 @@ +@using MeterVault.App.Energy +@using MeterVault.Core.Analysis +@using MeterVault.Core.Analysis.Totals +@using MeterVault.Infrastructure.Analysis +@inject NavigationManager Nav + +@* The energy type's Overview (brief §7.3): one card per measure — use, grid import, export, generation, runtime, each + with its own unit and status, never added to each other (D-22) — the type's bill with its basis and standing charge + (D-34, D-40), the comparison over matched coverage (D-07), a compact trend, the data's coverage, and the meters that + changed most. *@ + +@if (_quantities is not null) +{ +
+ + +@if (_pending) +{ + +} + +@if (_measures.Count == 0) +{ + + @S.EnergyView_NoMeasures + @S.Nav_Meters + +} +else if (_quantities.NotYetOccurred || _noData) +{ + +} +else +{ + + @foreach (var series in _measures) + { + + + + } + @if (Analysis.Cost is { } cost && Analysis.Metrics.Contains(AnalysisMetric.Cost)) + { + + + @foreach (var line in _costLines) + { + @line + } + + + } + + + + + + + +
+ @_trendTitle + @S.EnergyView_OpenHistory +
+ +
+
+ + + @S.EnergyView_Coverage + @if (_quantities.Availability.Quantity is { } available) + { + @Loc.F(S.Empty_AvailableRange, Format.Date(available.FirstDay), Format.Date(available.LastDay)) + } + else + { + @S.Empty_NoDataYet + } +
+ @foreach (var series in _measures) + { + var status = FigureText.Of(series.Total, Analysis.Names.MeterOrNull); +
@EnergyHistoryView.MeasureName(series, _measures)
+
+ @status.Summary + @if (series.Resolution is { } resolution) + { + · @resolution.Display() + } + @if (series.Freshness.State != FreshnessState.NoData) + { + · @series.Freshness.State.Display() + } + @if (status.IsQualified && status.Detail is { } detail) + { +
@detail
+ } +
+ } +
+
+
+ + + @S.EnergyView_LargestChanges + @if (_quantities.Comparison is not { IsApplicable: true }) + { + @S.EnergyView_NoComparison + } + else if (_changes.Count == 0) + { + @S.EnergyView_NoComparableMeters + } + else + { + @S.EnergyView_LargestChangesNote +
+ + + + @S.Common_Meter + @S.EnergyView_ColCurrent + @S.AnalysisTable_Comparison + @S.AnalysisTable_Change + + + + @foreach (var change in _changes) + { + var series = change.Series; + + + @series.Name + @if (Analysis.TotalsOf(series.MeterId!.Value) is { IsCounted: false } entry) + { +
@entry.Class.Display()
+ } + + + @Format.Quantity(change.Current, series.Unit) +
@Format.DateRange(change.Matched.Current!.FirstDay, change.Matched.Current.LastDay)
+ + + @Format.Quantity(change.Previous, series.Unit) +
@Format.DateRange(change.Matched.Comparison!.FirstDay, change.Matched.Comparison.LastDay)
+ + + + + + } + +
+
+ } +
+
+
+} +
+} + +@code { + /// The page's committed value. + [Parameter, EditorRequired] + public EnergyAnalysis Analysis { get; set; } = null!; + + /// The page's analysis state (links carry its period). + [Parameter, EditorRequired] + public AnalysisQuery Query { get; set; } = null!; + + /// The page's defaults, for "go to latest data" on the page itself. + [Parameter, EditorRequired] + public AnalysisDefaults Defaults { get; set; } = null!; + + /// Loads again (analysis being prepared). + [Parameter] + public EventCallback OnRefresh { get; set; } + + private const int MaxChanges = 6; + + private EnergyAnalysis? _builtFor; + private AnalysisResult? _quantities; + private List _measures = []; + private AnalysisSeries? _main; + private IReadOnlyList _trend = []; + private IReadOnlyList _changes = []; + private string _trendTitle = string.Empty; + private string? _comparisonCaption; + private string? _costBasis; + private List _costLines = []; + private Change? _costChange; + private string? _costCaption; + private ChangePolarity _costPolarity = ChangePolarity.HigherIsWorse; + private bool _pending; + private bool _noData; + + protected override void OnParametersSet() + { + if (ReferenceEquals(_builtFor, Analysis)) + { + return; + } + + // Built once per committed value (the chart re-keys on a new list): every text in the reader's culture. + _builtFor = Analysis; + _quantities = Analysis.Quantities; + if (_quantities is null) + { + return; + } + + _measures = [.. _quantities.Measures.OrderBy(s => s.Key.Measure).ThenBy(s => s.Unit, StringComparer.Ordinal)]; + _main = _measures.FirstOrDefault(s => s.Key.Measure == TotalsMeasure.Use) ?? _measures.FirstOrDefault(); + _pending = _measures.Any(s => s.IsPending); + _noData = _measures.Count > 0 && _measures.All(s => s.Total.Status == BucketStatus.Missing && !s.IsPending); + _comparisonCaption = _quantities.Comparison is { IsApplicable: true } ? Query.Comparison.Display() : null; + + if (_main is { } main) + { + var name = EnergyHistoryView.MeasureName(main, _measures); + _trendTitle = Loc.F(S.EnergyView_TrendOf, name); + List trend = [AnalysisChartSeries.ForSeries(main, name)]; + if (AnalysisChartSeries.ComparisonOf(main, AnalysisChartSeries.ComparisonName(name, Query.Comparison)) is { } overlay) + { + trend.Add(overlay); + } + + _trend = trend; + } + else + { + _trendTitle = S.EnergyView_Trend; + _trend = []; + } + + _changes = MeterChanges.Largest(_quantities.Series, MaxChanges); + BuildCost(); + } + + private void BuildCost() + { + _costBasis = null; + _costLines = []; + _costChange = null; + _costCaption = null; + if (Analysis.Cost is not { } cost) + { + return; + } + + var figure = cost.EnergyTypes.FirstOrDefault(t => t.EnergyTypeId == Analysis.EnergyTypeId); + var billed = cost.Lines.Where(l => l.Kind != BillLineKind.FeedIn).Select(l => l.Name).Distinct().ToList(); + if (figure is { } type) + { + _costBasis = billed.Count > 0 ? Loc.F(S.EnergyView_CostBasis, type.Basis.Display(), string.Join(", ", billed)) : type.Basis.Display(); + } + + var credited = cost.Lines.Where(l => l.Kind == BillLineKind.FeedIn).Select(l => l.Name).Distinct().ToList(); + if (credited.Count > 0) + { + _costLines.Add(Loc.F(S.EnergyView_CostCredit, string.Join(", ", credited))); + } + + // Every standing charge of the bill (D-40) — the type's rows and the meter fees on its lines — as the Overview and + // the Analysis page show it for the same scope. + if (Analysis.StandingCharge is { } standing && standing != 0) + { + _costLines.Add(Loc.F(S.EnergyView_StandingCharge, Format.Money(standing, cost.Currency))); + } + + if (cost.ManualCosts.Bookings.Count > 0) + { + _costLines.Add(Loc.F(S.EnergyView_ManualCosts, Format.Money(cost.ManualCosts.Total.Cost, cost.Currency))); + } + + // The change over what both bills cover completely, by the rule every page uses (D-07). + var change = Analysis.CostChange; + _costChange = CostChanges.ForCard(change); + _costPolarity = CostChanges.Polarity(change); + _costCaption = CostChanges.Caption(Query, change); + } + + private Change? ChangeOf(AnalysisSeries series) => + _quantities?.Comparison is { IsApplicable: true } ? series.Comparison?.Change ?? Change.Unavailable : null; + + private string? MembersCaption(AnalysisSeries series) => + series.MemberIds.Count == 0 ? null : Loc.F(S.EnergyView_CountedMeters, string.Join(", ", series.MemberIds.Select(Analysis.MeterName))); + + private string HistoryHref(AnalysisMetric? metric) => + AnalysisLinks.EnergyType(Analysis.EnergyTypeId, AnalysisLinks.EnergyTabHistory, metric is { } m ? Query.WithMetric(m) : Query); + + private string? LatestHref => + AnalysisNavigation.LatestData(Query, _quantities?.Availability.Quantity) is { } latest + ? AnalysisNavigation.UriFor(Nav, latest, Defaults) + : null; +} diff --git a/src/App/Components/Pages/Energy/EnergyOverviewTab.razor.css b/src/App/Components/Pages/Energy/EnergyOverviewTab.razor.css new file mode 100644 index 0000000..f8b7fc3 --- /dev/null +++ b/src/App/Components/Pages/Energy/EnergyOverviewTab.razor.css @@ -0,0 +1,31 @@ +.mv-energy-overview ::deep .mv-energy-panel { + height: 100%; +} + +.mv-energy-panel__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 4px 12px; + margin-bottom: 8px; +} + +.mv-energy-coverage { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 2px; + margin: 12px 0 0 0; + font-size: 0.875rem; +} + +.mv-energy-coverage dt { + font-weight: 500; + margin-top: 6px; +} + +.mv-energy-coverage dd { + margin: 0; + color: var(--mud-palette-text-secondary); + overflow-wrap: anywhere; +} diff --git a/src/App/Components/Pages/Energy/ManageConnectionsDialog.razor b/src/App/Components/Pages/Energy/ManageConnectionsDialog.razor new file mode 100644 index 0000000..4a6c82a --- /dev/null +++ b/src/App/Components/Pages/Energy/ManageConnectionsDialog.razor @@ -0,0 +1,302 @@ +@using MeterVault.App.Energy +@using Microsoft.EntityFrameworkCore +@inject IDbContextFactory DbFactory +@inject ISnackbar Snackbar +@inject ILogger Logger + +@* "Manage connections" (brief §3.2, D-30): an energy type's flow topology with clear source → destination names, where + a connection is added or removed on the spot. The rules (MeterLinkRules) refuse a meter into itself, a duplicate, a + link across types, a loop — the verdict names it before anything is saved — and any change to the incoming + connections of a virtual meter still calculated from them. A connection is topology only: it never writes or changes + a calculated meter's formula (D-25); one that mirrors a formula input says so. *@ + + + + @Loc.F(S.EnergyView_ConnectionsTitle, EnergyTypeName) + + + @S.EnergyView_ConnectionsIntro + + @if (_error) + { + + } + else if (_topology is null) + { + + } + else + { + @S.EnergyView_Connections + @if (_topology.Links.Count == 0) + { + @S.EnergyView_ConnectionsEmpty + } + else + { +
    + @foreach (var link in _topology.Links) + { + var from = Name(link.FromMeterId); + var to = Name(link.ToMeterId); + var removal = _topology.CheckRemove(link); +
  • +
    + @from +
    +
    + @if (!removal.IsAllowed) + { + @FlowText.Refusal(removal, Name, link.ToMeterId) + @S.EnergyView_EditCalculation + } + else if (_topology.MirrorsCalculation(link)) + { + @Loc.F(S.EnergyView_MirrorsCalculation, to, from) + } +
    + +
  • + } +
+ } + + @S.EnergyView_AddConnection + @if (_topology.Meters.Count < 2) + { + @S.EnergyView_ConnectionsNeedTwo + } + else + { +
+
+ + @S.EnergyView_ChooseMeter + @foreach (var meter in _topology.Meters) + { + @Label(meter) + } + +
+
+ + @S.EnergyView_ChooseMeter + @foreach (var meter in _topology.Meters) + { + @Label(meter) + } + +
+ @S.EnergyView_AddConnectionAction +
+ @* A fixed slot, so the verdict appearing does not move the button under the pointer. *@ +
+ @if (_verdict is { IsAllowed: false } refused) + { +
+ } + } +
+ + @S.EnergyView_Done + +
+ +@code { + /// The energy type whose connections are edited. + [Parameter, EditorRequired] + public int EnergyTypeId { get; set; } + + /// Its name, for the title (user data). + [Parameter] + public string EnergyTypeName { get; set; } = string.Empty; + + /// Raised when the dialog closes after at least one change, so the page reads the analysis again. + [Parameter] + public EventCallback Changed { get; set; } + + private readonly DialogOptions _options = new() { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true }; + private MeterLinkService? _service; + private MeterLinkTopology? _topology; + private MeterLinkCheck? _verdict; + private bool _open; + private bool _busy; + private bool _error; + private bool _changed; + private int _from; + private int _to; + + private MeterLinkService Service => _service ??= new MeterLinkService(DbFactory); + + /// Opens the dialog and reads the type's connections afresh. + public async Task OpenAsync() + { + _open = true; + _changed = false; + _from = 0; + _to = 0; + _verdict = null; + await LoadAsync(); + } + + private async Task LoadAsync() + { + _error = false; + _topology = null; + StateHasChanged(); + try + { + _topology = await Service.GetAsync(EnergyTypeId); + Check(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogError(ex, "Loading the connections of energy type {EnergyTypeId} failed", EnergyTypeId); + _error = true; + } + + // The dialog renders through the dialog provider: say so when the list arrives, whoever awaited it. + StateHasChanged(); + } + + private string Name(int meterId) + { + if (_topology?.Find(meterId) is not { } meter) + { + return MeterMembership.FallbackName(meterId); + } + + return meter.EnergyTypeId == EnergyTypeId ? meter.Name : Loc.F(S.EnergyView_OtherTypeMeter, meter.Name); + } + + /// The meter's name, marked when it is calculated or retired — the two things that change what a link means. + private static string Label(MeterLinkMeter meter) + { + var marks = new List(2); + if (meter.IsVirtual) + { + marks.Add(meter.Mode.Display()); + } + + if (!meter.IsActive) + { + marks.Add(S.Meters_Retired); + } + + return marks.Count == 0 ? meter.Name : meter.Name + " (" + string.Join(", ", marks) + ")"; + } + + private void OnFromChanged(int id) + { + _from = id; + Check(); + } + + private void OnToChanged(int id) + { + _to = id; + Check(); + } + + private void Check() => + _verdict = _topology is not null && _from != 0 && _to != 0 ? _topology.CheckAdd(_from, _to) : null; + + private async Task AddAsync() + { + await RunAsync(async () => + { + var result = await Service.AddAsync(_from, _to); + if (result.IsAllowed) + { + Snackbar.Add(Loc.F(S.EnergyView_ConnectionAdded, Name(_from), Name(_to)), Severity.Success); + _from = 0; + _to = 0; + } + else + { + Snackbar.Add(FlowText.Refusal(result, Name, _to), Severity.Warning); + } + + return result.IsAllowed; + }); + } + + private async Task RemoveAsync(MeterLinkEntry link) + { + var from = Name(link.FromMeterId); + var to = Name(link.ToMeterId); + await RunAsync(async () => + { + var result = await Service.RemoveAsync(link.LinkId); + Snackbar.Add( + result.IsAllowed ? Loc.F(S.EnergyView_ConnectionRemoved, from, to) : FlowText.Refusal(result, Name, link.ToMeterId), + result.IsAllowed ? Severity.Success : Severity.Warning); + return result.IsAllowed; + }); + } + + /// One change at a time; the list is read again afterwards, so it always shows what is stored. + private async Task RunAsync(Func> change) + { + if (_busy) + { + return; + } + + _busy = true; + try + { + _changed |= await change(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogError(ex, "Changing a connection of energy type {EnergyTypeId} failed", EnergyTypeId); + Snackbar.Add(S.EnergyView_ConnectionFailed, Severity.Error); + } + finally + { + _busy = false; + } + + await LoadAsync(); + } + + private async Task OnVisibleChanged(bool visible) + { + if (!visible) + { + await CloseAsync(); + } + } + + private async Task CloseAsync() + { + _open = false; + if (_changed) + { + _changed = false; + await Changed.InvokeAsync(); + } + } +} diff --git a/src/App/Components/Pages/Energy/ManageConnectionsDialog.razor.css b/src/App/Components/Pages/Energy/ManageConnectionsDialog.razor.css new file mode 100644 index 0000000..f9c5f78 --- /dev/null +++ b/src/App/Components/Pages/Energy/ManageConnectionsDialog.razor.css @@ -0,0 +1,75 @@ +.mv-connections { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +.mv-connections__item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: "names remove" "note remove"; + align-items: center; + gap: 0 8px; + padding: 6px 0; + border-bottom: 1px solid var(--mud-palette-lines-default); +} + +.mv-connections__names { + grid-area: names; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 6px; + min-width: 0; +} + +.mv-connections__meter { + overflow-wrap: anywhere; +} + +.mv-connections__note { + grid-area: note; + display: flex; + flex-wrap: wrap; + gap: 2px 8px; + font-size: 0.75rem; + color: var(--mud-palette-text-secondary); +} + +.mv-connections__note:empty { + display: none; +} + +.mv-connections__item ::deep .mv-connections__remove { + grid-area: remove; +} + +.mv-connections__add { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 8px 12px; +} + +.mv-connections__select { + flex: 1 1 200px; + min-width: 0; +} + +.mv-connections__add ::deep .mv-connections__button { + align-self: center; + min-height: 40px; +} + +.mv-connections__verdict { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + min-height: 3em; + margin-top: 4px; + font-size: 0.875rem; + color: var(--mud-palette-text-secondary); +} diff --git a/src/App/Components/Pages/EnergyView.razor b/src/App/Components/Pages/EnergyView.razor index 3761cc7..8818453 100644 --- a/src/App/Components/Pages/EnergyView.razor +++ b/src/App/Components/Pages/EnergyView.razor @@ -1,196 +1,267 @@ @page "/energy/{Id:int}" -@inject FlowService Flow -@inject MeterVault.Infrastructure.Costing.CostService Costs -@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory -@inject Microsoft.Extensions.Options.IOptions Options +@using MeterVault.App.Energy +@using MeterVault.App.Components.Pages.Energy +@using MeterVault.App.Components.Shared.MeterLists +@using MeterVault.Core.Analysis +@using MeterVault.Infrastructure.Analysis @using Microsoft.EntityFrameworkCore -@using MudBlazor +@inject NavigationManager Nav +@inject InstanceClock Clock +@inject AnalysisPeriods Periods +@inject AnalysisReader Reader +@inject CostReader Costs +@inject FlowService Flow +@inject IDbContextFactory DbFactory +@inject ILogger Logger +@implements IDisposable -MeterVault — @(_graph?.EnergyType ?? S.EnergyView_EnergyFallback) +@* One energy type (brief §7.3): its user-defined name as the title, one period toolbar for every tab, and the tabs + Overview | History | Flow | Meters by key (tab=…, written with replace). The type is read once per period, bucket and + comparison — its measures and every meter's own series, its bill, the comparison bill and the flow of those same + totals — so a tab switch, the History view or the metric never reloads anything (D-46). *@ -
- @Loc.F(S.EnergyView_FlowTitle, _graph?.EnergyType ?? S.EnergyView_EnergyFallback) - - @S.Common_RangeLast12Months - @S.Common_RangeLast24Months - @S.Common_RangeLast5Years - @S.Common_RangeAllTime - -
+ + + + + + @if (_state.Value is { Type: not null } header) + { + @if (header.HasGeneration) + { + @S.Nav_Solar + } + @if (header.HasTank) + { + @S.Nav_Consumables + } + @S.EnergyView_EditDefinition + } + + -@if (_graph is null) +@if (_query is not null) { - + } -else if (_meters.Count == 0) -{ - - @S.EnergyView_NoMetersIntro @S.Nav_Meters@S.EnergyView_NoMetersOr - @S.Nav_Import. - -} -else -{ - @* Meters without consumption in the range still exist — a new one, or a quiet stretch — and this - page is a natural way in to them, so the list below always renders; only the figures wait. *@ - @if (!_graph.HasData) + +
+ + @if (analysis.Type is null) { - @S.EnergyView_NoDataInRange + @S.EnergyView_NotFound + } + else if (analysis.Meters.Count == 0) + { +
+
} else { - - - - @S.EnergyView_TopLevelThroughput - @Format.Number(_graph.Total, 0) @_graph.Unit - - - - - @S.Common_CostRange - @Format.Euro(_cost) - - - - - @S.Common_Meters - @_meters.Count - - - - - - @S.EnergyView_Flow - @if (_graph.HasChain) - { - - @S.EnergyView_FlowCaption - - - } - else - { - - @S.EnergyView_NoChainIntro @S.Nav_Meters @S.EnergyView_NoChainMiddle - @S.EnergyView_NoChainUpstream @S.EnergyView_NoChainRest - - @if (_graph.Nodes.Count > 0) - { - - @S.Common_Meter@S.EnergyView_ColConsumption - - @foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value)) - { - - @(node.IsOther ? Loc.F(S.Flow_OtherNode, node.Label) : node.Label) - @Format.Number(node.Value, 0) @_graph.Unit - - } - - - } - } - + + + + + + + + + + + +
+ @S.EnergyView_MetersHelp + @S.EnergyView_ManageConnections +
+ +
+
} +
+
- - @S.Common_Meters - - @S.Common_Name@S.Common_Mode@S.EnergyView_ColUpstreamOf@S.EnergyView_ColConsumption - - @foreach (var meter in _meters) - { - - @meter.Name - @meter.Mode.Display() - @UpstreamLabel(meter.Id) - @Format.Number(NodeValue(meter.Id), 0) @_graph.Unit - - @if (MeterLinks.QuickEntry(meter.Id, meter.Mode) is { } entry) - { - - - - } - - - } - - - -} + @code { [Parameter] public int Id { get; set; } - private int _months = 60; - private bool _loading; - private FlowGraph? _graph; - private double _cost; - private List _meters = []; - private Dictionary> _downstream = []; + private readonly LoadSequencer _loads = new(); + private readonly LoadState _state = new(); + private AnalysisQuery? _query; + private (int Id, AnalysisQuery Query)? _loaded; + private string _tab = AnalysisLinks.EnergyTabOverview; + private string _view = EnergyPageKeys.ViewTotal; + private ManageConnectionsDialog? _connections; + private EnergyAnalysis? _rowsFor; + private IReadOnlyList _rows = []; - protected override Task OnParametersSetAsync() => LoadAsync(); + /// The History defaults (last 12 months, automatic, previous year) on a page whose route names the type. + private AnalysisDefaults Defaults => AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(Math.Max(1, Id))); - private async Task OnRangeChanged(int months) + private string Title => _state.Value is { EnergyTypeId: var shown, Type: { } type } && shown == Id ? type.Name : S.EnergyView_EnergyFallback; + + /// The interval and the comparison change only the Overview's and the History's figures. + private bool ShowsBuckets => _tab is AnalysisLinks.EnergyTabOverview or AnalysisLinks.EnergyTabHistory; + + private BucketPlan? ToolbarPlan => _state.Value is { } value ? value.RefusedPlan ?? value.Quantities?.Plan : null; + + private AnalysisMetric? NaturalMetric => _state.Value is { Metrics.Count: > 0 } value ? value.Metrics[0] : null; + + /// + /// The History's CSV (D-55): the type's measures of the metric, or — in the individual view — the meters it charts. + /// + private string? ExportHref { - _months = months; - await LoadAsync(); + get + { + if (_tab != AnalysisLinks.EnergyTabHistory || _query is null || Id <= 0) + { + return null; + } + + var metric = _state.Value is { } value ? EffectiveMetric(value) : _query.Metric; + var scope = QueryScope.ForEnergyType(Id); + if (_view == EnergyPageKeys.ViewMeters && metric is { } m && m.IsQuantity() + && EnergyMetrics.MetersOf(_state.Value?.Quantities, m).Take(AnalysisLimits.MaxSeries).Select(s => s.MeterId!.Value).ToList() is { Count: > 0 } shown) + { + scope = QueryScope.ForMeters(shown); + } + + return AnalysisLinks.Export(_query.WithScope(scope).WithMetric(metric)); + } } - private async Task LoadAsync() + protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged; + + protected override Task OnParametersSetAsync() => SyncAsync(); + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () => { - if (_loading) + // Only this page's own address: a link away fires this too, just before the page goes. + if (!IsThisPage(e.Location)) { return; } - _loading = true; - _graph = null; - try - { - var asOf = DateOnly.FromDateTime(DateTime.UtcNow); - var from = new DateOnly(asOf.AddMonths(-_months).Year, asOf.AddMonths(-_months).Month, 1); - var to = asOf.AddMonths(1); - var typeId = (short)Id; + await SyncAsync(); + StateHasChanged(); + }); - // Assigned last: the page branches on the graph being loaded, and must not render it - // against the previous type's (or an empty) meter list in between. - var graph = await Flow.GetFlowAsync(typeId, from, to); - - await using var db = await DbFactory.CreateDbContextAsync(); - _meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync(); - var links = await db.MeterLinks.AsNoTracking() - .Where(l => _meters.Select(m => m.Id).Contains(l.FromMeterId)) - .ToListAsync(); - var names = _meters.ToDictionary(m => m.Id, m => m.Name); - _downstream = links - .GroupBy(l => l.FromMeterId) - .ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList()); - - var zone = MeterVault.Infrastructure.Options.InstanceTimeZone.Resolve(Options.Value.TimeZone); - var fromUtc = MeterVault.Infrastructure.Options.InstanceTimeZone.StartOf(from, zone); - var toUtc = MeterVault.Infrastructure.Options.InstanceTimeZone.StartOf(to, zone); - double cost = 0; - foreach (var meter in _meters) - { - cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost); - } - _cost = cost; - _graph = graph; - } - finally - { - _loading = false; - } + private bool IsThisPage(string uri) + { + var path = Nav.ToBaseRelativePath(uri); + var end = path.IndexOfAny(['?', '#']); + path = (end >= 0 ? path[..end] : path).TrimEnd('/'); + return string.Equals(path, "energy/" + Id.ToString(System.Globalization.CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase); } - private double NodeValue(int meterId) => _graph?.Nodes.FirstOrDefault(n => n.MeterId == meterId)?.Value ?? 0; + /// Reads the address: the tab and view always, the analysis only when period, bucket or comparison changed. + private async Task SyncAsync() + { + (_tab, _view) = EnergyPageKeys.Parse(Nav.Uri); + var query = AnalysisQuery.Parse(Nav.Uri, Defaults); + _query = query; - private string UpstreamLabel(int meterId) => - _downstream.TryGetValue(meterId, out var children) && children.Count > 0 ? string.Join(", ", children) : "—"; + var key = (Id, LoadKey(query)); + if (_loaded == key) + { + return; + } + + if (_loaded?.Id != Id) + { + // Another type: its figures must not show under this title, not even dimmed. + _state.Clear(); + } + + _loaded = key; + await LoadAsync(query); + } + + /// What the read depends on: the metric only picks what History charts, the scope is the route's. + private AnalysisQuery LoadKey(AnalysisQuery query) => EnergyAnalysisLoader.LoadKey(query, Id); + + private Task Retry() => _query is null ? Task.CompletedTask : LoadAsync(_query); + + private Task LoadAsync(AnalysisQuery query) + { + var id = Id; + return _loads.RunAsync(_state, token => ReadAsync(id, query, token), Logger); + } + + private Task ReadAsync(int id, AnalysisQuery query, CancellationToken token) => + new EnergyAnalysisLoader(DbFactory, Periods, Reader, Costs, Flow).LoadAsync(id, query, Clock.Now, token); + + /// The metric History charts: the address's when the type has it, else the type's first (consumption first). + private AnalysisMetric EffectiveMetric(EnergyAnalysis analysis) => EnergyMetrics.Effective(_query?.Metric, analysis.Metrics); + + private IReadOnlyList MeterRows(EnergyAnalysis analysis) + { + // Built once per committed value: the list keeps its search and filter across renders. + if (!ReferenceEquals(_rowsFor, analysis)) + { + _rowsFor = analysis; + _rows = MeterListRows.Build(analysis.Meters, analysis.Quantities); + } + + return _rows; + } + + private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults); + + /// A tab click writes tab= (replace, D-46); the Overview is the default and is not written. + private void OnTabChanged(int index) + { + var tab = index >= 0 && index < AnalysisLinks.EnergyTabs.Count ? AnalysisLinks.EnergyTabs[index] : AnalysisLinks.EnergyTabOverview; + if (tab == _tab) + { + return; + } + + _tab = tab; + Nav.NavigateTo(Nav.GetUriWithQueryParameter(EnergyPageKeys.Tab, tab == AnalysisLinks.EnergyTabOverview ? null : tab), replace: true); + } + + private void OnViewChanged(string view) + { + if (view == _view) + { + return; + } + + _view = view; + Nav.NavigateTo(Nav.GetUriWithQueryParameter(EnergyPageKeys.View, view == EnergyPageKeys.ViewTotal ? null : view), replace: true); + } + + private Task OpenConnectionsAsync() => _connections?.OpenAsync() ?? Task.CompletedTask; + + /// The topology changed: the measures, the bill and the flow may classify differently, so read again. + private Task OnConnectionsChanged() => Retry(); + + public void Dispose() + { + Nav.LocationChanged -= OnLocationChanged; + _loads.Dispose(); + } } diff --git a/src/App/Components/Pages/EnergyView.razor.css b/src/App/Components/Pages/EnergyView.razor.css new file mode 100644 index 0000000..fb16cdc --- /dev/null +++ b/src/App/Components/Pages/EnergyView.razor.css @@ -0,0 +1,10 @@ +/* Four tabs fit a phone: plain case and tighter padding below 600px, so none of them is cut off or scrolled away. */ +@media (max-width: 599.98px) { + .mv-energy-page ::deep .mv-energy-tabs .mud-tab { + /* MudTabs sets the tab's minimum width inline. */ + min-width: 0 !important; + padding: 6px 8px; + text-transform: none; + letter-spacing: normal; + } +} diff --git a/src/App/Components/Pages/MeterDetail.razor b/src/App/Components/Pages/MeterDetail.razor index e280f58..2943f67 100644 --- a/src/App/Components/Pages/MeterDetail.razor +++ b/src/App/Components/Pages/MeterDetail.razor @@ -1,640 +1,125 @@ @page "/meters/{Id:int}" +@using MeterVault.App.Components.Pages.MeterPage +@using MeterVault.App.MeterDetails +@using MeterVault.Core.Analysis @inject MeterDetailService Details -@inject MeterPeriodService Periods -@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory -@inject ISnackbar Snackbar -@inject IDialogService DialogService +@inject AnalysisPeriods Periods +@inject MeterVault.Infrastructure.Analysis.AnalysisReader Reader +@inject CostReader Costs +@inject InstanceClock Clock @inject NavigationManager Nav -@inject IServiceScopeFactory Scopes -@inject Microsoft.Extensions.Options.IOptions Options @inject ILogger Logger -@inject DraftStore Drafts @implements IDisposable -@using System.Globalization -@using Microsoft.EntityFrameworkCore -@using Microsoft.Extensions.DependencyInjection -@using MeterVault.Infrastructure.Ingestion -@using MudBlazor -MeterVault — @(_detail?.Name ?? S.Common_Meter) +@* The per-meter hub (brief §7.2, SDD §8.6): the header with the meter's identity and its actions, the tab bar directly + below it, and the analysis in the default Analysis tab — so Sources and Events never sit under a wall of charts. + Tabs are addressed by stable keys (D-47) and written back to the address on a click (replace); the analysis reloads + only when its own keys change (D-46), and the one-shot `action` is consumed once, separately from both. *@ @if (_detail is null) { + MeterVault — @S.Common_Meter @if (_notFound) { @Loc.F(S.MeterDetail_NotFound, Id) @S.MeterDetail_BackToMeters } + else if (_detailFailed) + { + + } else { - + } } else { - @* Big touch targets and tabular digits for the manual-reading dialog: it is used standing at a - meter on a phone, where the default input sizes are fiddly. *@ - - - @* The header carries the meter's actions, above the figures: on a phone the tabs sit a long - scroll down, and the things people come here to do — enter a reading, record a swap, fix a - setting — should not depend on finding the right tab first. *@ -
- - @_detail.Name - - @_detail.EnergyType - - @_detail.Mode.Display() - @if (!_detail.IsActive) + + @if (_detail.Mode == MeterMode.ConsumableBalance && !_detail.HasTank) { - @S.MeterDetail_Retired + + @S.MeterDetail_NoTankConfigured + @S.MeterDetail_SetUpTank + } - -
- @if (TakesReadings) - { - - @S.MeterDetail_AddReading - - } - else if (_detail.Mode == MeterMode.ConsumableBalance) - { - - @S.MeterDetail_RecordTankLevel - - } - - @foreach (var type in MeterEventRules.RecordableFor(_detail.Mode)) - { - var chosen = type; - @($"{chosen.Display()}…") - } - - - - -
-
- @if (IdentityLine() is { Length: > 0 } identity) - { - @identity - } - else - { -
- } - - @if (_detail.Mode == MeterMode.ConsumableBalance && !_detail.HasTank) - { - - @S.MeterDetail_NoTankConfigured - @S.MeterDetail_SetUpTank - - } - else if (IsUnstarted) - { - @* A brand-new meter has nothing to show yet; say what makes it useful instead of a page of dashes. *@ - - @S.MeterDetail_GetStarted -
- @if (TakesReadings) - { - @S.MeterDetail_AddFirstReading - @S.MeterDetail_ConnectSource - } - else - { - @S.MeterDetail_RecordTankLevel - } -
-
- } - - @if (_periods is { } p) - { - - - - @Loc.F(S.MeterDetail_LabelThisMonth, p.Kind.Display()) - @Format.Number(p.MonthToDate, 0) @p.Unit - @if (p.MonthIsPartial) - { - - @Loc.F(S.MeterDetail_ProjectedByMonthEnd, Format.Number(p.MonthProjected, 0), p.Unit) - - } - - - - - @S.MeterDetail_VsLastMonth - @ChangeText(p.MonthChange) - - @Loc.F(S.MeterDetail_LastMonthValue, Format.Number(p.LastMonth, 0), p.Unit) - - - - - - @S.Common_ThisYear - @Format.Number(p.YearToDate, 0) @p.Unit - - @Loc.F(S.MeterDetail_VsLastYear, ChangeText(p.YearChange), Format.Number(p.LastYear, 0)) - - - - - - @S.MeterDetail_CostThisYear - @Format.Number(p.YearToDateCost, 2) @p.Currency - - @Loc.F(S.MeterDetail_ProjectedFullYear, Format.Number(p.YearProjectedCost, 0), p.Currency) - @(p.LastYearCost > 0 ? Loc.F(S.MeterDetail_LastYearCost, Format.Number(p.LastYearCost, 0)) : "") - - - - - - @if (p.HasHistory) + else if (IsUnstarted) { - - @S.Common_RangeLast12Months -
- @foreach (var m in p.Last12Months) + @* A brand-new meter has nothing to show yet; say what makes it useful instead of a page of dashes. *@ + + @S.MeterDetail_GetStarted +
+ @if (TakesReadings) { -
-
-
-
- @m.Month.ToString("MMM") -
+ @S.MeterDetail_AddFirstReading + @S.MeterDetail_ConnectSource + } + else + { + @S.MeterDetail_RecordTankLevel }
- +
} - } + - @if (_periods is null && _detail.Mode == MeterMode.Virtual) - { - - @S.MeterDetail_VirtualNotice @S.MeterDetail_VirtualNoticeFlow - - } - - - -
-
- @S.MeterDetail_RegisterSpan - - @(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") → - @(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—") - @Loc.F(S.MeterDetail_BaselineValue, Format.Number(_detail.InitialBaseline, 0)) - -
-
- @S.MeterDetail_Readings - - @_detail.ReadingCount · - @(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—") - -
-
- @S.MeterDetail_LifetimeTotal - - @Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit - -
-
-
-
- - - - @if (_detail.Mode == MeterMode.Virtual) - { - - @S.MeterDetail_VirtualNoReadings - - } - else if (_detail.Mode == MeterMode.ConsumableBalance) - { - @* A tank's consumption comes from level and delivery events; a reading typed here would - save cleanly and change nothing, so the tab sends the user where it counts. *@ - - @S.MeterDetail_TankUsesEvents - @S.MeterDetail_GoToEvents - - } - else - { -
- - @S.MeterDetail_AddReading - -
- } - @if (_detail.RecentReadings.Count == 0) - { - @if (_detail.Mode != MeterMode.ConsumableBalance) + + @foreach (var key in MeterLinks.VisibleTabs(_detail.Mode)) + { + +
+ @switch (key) { - @S.MeterDetail_NoRawReadings + case MeterLinks.TabAnalysis: + + break; + case MeterLinks.TabReadings: + + break; + case MeterLinks.TabNormalized: + + break; + case MeterLinks.TabEvents: + + break; + case MeterLinks.TabTariffs: + + break; + case MeterLinks.TabSources: + + break; + case MeterLinks.TabCalculation: + + break; } - } - else - { - - @Loc.F(S.MeterDetail_RecentReadingsCaption, _detail.RecentReadings.Count, _tz.Id) - - - @S.MeterDetail_Time@S.Common_Value@S.MeterDetail_Quality@S.MeterDetail_Flags - - @foreach (var r in _detail.RecentReadings) - { - - @Local(r.Time).ToString("yyyy-MM-dd HH:mm") - @Format.Number(r.Value, 2) @_detail.Unit - @QualityChip(r.Quality) - @r.Flags.Display() - - @if (r.Quality == ReadingQuality.Manual) - { - - - - } - - - } - - - } - - - - @if (_detail.RecentConsumption.Count == 0) - { - @S.MeterDetail_NoConsumption - } - else - { - @Loc.F(S.MeterDetail_RecentConsumptionCaption, _detail.RecentConsumption.Count) - - @S.MeterDetail_Time@S.Common_Amount@S.MeterDetail_Kind@S.MeterDetail_Quality - - @foreach (var c in _detail.RecentConsumption) - { - - @Local(c.Time).ToString("yyyy-MM-dd HH:mm") - @Format.Number(c.Amount, 2) @_detail.Unit - @c.Kind.Display() - @QualityChip(c.Quality) - - } - - - } - - - -
- @EventsHint - - @foreach (var type in MeterEventRules.RecordableFor(_detail.Mode)) - { - var chosen = type; - @($"{chosen.Display()}…") - } - -
- @if (_detail.Events.Count == 0) - { - @S.MeterDetail_NoEvents - } - else - { - - @S.MeterDetail_Time@S.Common_Type@S.Common_Amount@S.MeterDetail_PrevNew@S.MeterDetail_Notes - - @foreach (var e in _detail.Events) - { - - @Local(e.Time).ToString("yyyy-MM-dd HH:mm") - - @e.Type.Display() - - @(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—") - @PrevNewText(e) - @e.Notes - - @if (e.ImportBatchId is not null) - { - - @S.MeterDetail_Imported - - } - else - { - - - - } - - - } - - - } -
- - -
- @S.MeterDetail_ManageTariffs -
- @if (_detail.Tariffs.Count == 0) - { - @S.MeterDetail_NoTariffs - } - else - { - - @S.Common_Scope@S.MeterDetail_Component@S.Common_Value@S.Common_Unit@S.MeterDetail_From@S.MeterDetail_To - - @foreach (var t in _detail.Tariffs) - { - - @ScopeText(t) - @t.Component.Display() - @Format.Number(t.Value, 4) - @t.Unit - @t.ValidFrom.ToString("yyyy-MM-dd") - @(t.ValidTo?.ToString("yyyy-MM-dd") ?? S.MeterDetail_TariffOpenEnd) - - } - - - } -
- - -
- - @S.MeterDetail_AddSource - -
- @if (_sources.Count == 0) - { - @S.MeterDetail_NoSources - } - else - { - - @S.Common_Type@S.Common_Target@S.MeterDetail_Connector@S.Common_Enabled@S.Common_LastSeen@S.MeterDetail_LastValue@S.Common_Status@S.Common_Actions - - @foreach (var s in _sources) - { - - @s.SourceType.Display() - @SourceTarget(s) - - @{ var problem = ConnectorProblem(s); } - @if (problem is null) - { - @(_endpoints.FirstOrDefault(e => e.Id == s.EndpointId)?.Name ?? "—") - } - else - { - - @problem - - } - - @(s.IsEnabled ? S.MeterDetail_Yes : S.MeterDetail_No) - @(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—") - @(s.LastValue is { } v ? Format.Number(v, 2) : "—") - @(s.LastStatus ?? "—") - - - - - - } - - - } -
+
+
+ }
- - - @Loc.F(S.MeterDetail_AddReadingTitle, _detail.Name) - - - @LastReadingCaption() - - - - @* Fixed height, and above the keypad on purpose. The verdict on a value has to be visible - while it is being typed — the keypad pushes anything below it off a phone screen — but - anything that grows or shrinks here would move the keys out from under the user's - thumb mid-entry. So the slot is always the same size whether or not it says anything. *@ -
- - @(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {_detail.Unit}" : S.MeterDetail_EnterValue) - - @if (WouldBeRejected) - { - @* Names the likely cause, but deliberately is not a button: this line shows for most of - an ordinary entry (every prefix of 12351 is below 12345) and sits just above the - keypad, so a slightly high tap on the top keys would leave the reading mid-entry. The - swap and reset buttons are in the alert below and on the rejection message. *@ - @S.MeterDetail_SwappedOrResetHint - } - else if (ChangeSinceLast is { } change) - { - @ChangeSinceText(change) - } -
- -
- @foreach (var key in Keypad) - { - var pressed = key; - @pressed - } -
- -
- - - @S.Common_Now -
- @Loc.F(S.MeterDetail_LocalTimeIn, _tz.Id) - - @* Everything below here can reflow freely: the dialog's buttons sit outside this scroll - area, so nothing the user is aiming at moves. *@ - @if (_readingWhen.IsSkipped) - { - - @Loc.F(S.MeterDetail_SkippedTime, _tz.Id) - - } - @if (WouldBeRejected) - { - - @Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(_detail.LastReadingValue ?? 0, 2), _detail.Unit) -
- @S.MeterDetail_RecordSwap - @S.MeterDetail_RecordReset -
-
- } - @if (ReplacesSwapStart) - { - @S.MeterDetail_ReplaceSwapStartNotice - } - else if (ReplacesRecentReading) - { - - @S.MeterDetail_ReplaceNotice - - } - @if (IsFuture) - { - @S.MeterDetail_FutureTime - } - else if (IsBackdated) - { - - @S.MeterDetail_BackdatedNotice - - } -
- - @S.Common_Cancel - - @(_readingSaving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading) - - -
- - - - @(_sourceEdit.Id == 0 ? S.MeterDetail_NewSource : S.MeterDetail_EditSource) - - - - @foreach (var type in Enum.GetValues()) - { - @type.Display() - } - - @if (SourceRouting.RequiredEndpoint(_sourceEdit.SourceType) is { } needed) - { - @* Every way to a missing connector leads back here with it picked, so setting one up is a - detour rather than a dead end that loses the meter. *@ - var usable = ConnectorsFor(needed); - if (usable.Count == 0) - { - - @if (_endpoints.FirstOrDefault(e => e.Type == needed && !e.IsEnabled) is { } disabled) - { - @Loc.F(S.MeterDetail_ConnectorOnlyDisabled, disabled.Name) @S.MeterDetail_EnableConnectorLink - } - else - { - @Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) @S.MeterDetail_CreateConnectorLink @S.MeterDetail_CreateConnectorHint - } - - } - else - { - - @foreach (var e in usable) - { - @e.Name - } - -
- - @S.MeterDetail_AnotherConnector - -
- } - } - @if (_sourceEdit.SourceType == SourceType.HomeAssistant) - { - - - - - @S.MeterDetail_PollHint - - } - else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota) - { - - - - } - - @foreach (var kind in Enum.GetValues()) - { - @kind.Display() - } - -
- - - -
- -
- - @S.Common_Cancel - @S.Common_Save - -
- - + + + } - + @code { [Parameter] public int Id { get; set; } - /// Which tab to open: one of . + /// Which tab to open: a stable key (); old keys resolve (D-47). [SupplyParameterFromQuery(Name = "tab")] public string? Tab { get; set; } @@ -654,88 +139,108 @@ else [SupplyParameterFromQuery(Name = MeterLinks.ParamConnector)] public int? ConnectorParam { get; set; } + // The analysis keys (D-46). Declared so a change to any of them re-runs OnParametersSet; the query itself is parsed + // from the whole address, which is what AnalysisQuery reads. + [SupplyParameterFromQuery(Name = AnalysisUrlKeys.Period)] + public string? PeriodKey { get; set; } + + [SupplyParameterFromQuery(Name = AnalysisUrlKeys.From)] + public string? FromKey { get; set; } + + [SupplyParameterFromQuery(Name = AnalysisUrlKeys.To)] + public string? ToKey { get; set; } + + [SupplyParameterFromQuery(Name = AnalysisUrlKeys.Bucket)] + public string? BucketKey { get; set; } + + [SupplyParameterFromQuery(Name = AnalysisUrlKeys.Compare)] + public string? CompareKey { get; set; } + + [SupplyParameterFromQuery(Name = AnalysisUrlKeys.Metric)] + public string? MetricKey { get; set; } + + private readonly LoadSequencer _detailLoads = new(); + private readonly LoadSequencer _analysisLoads = new(); + private readonly LoadState _analysis = new(); + private MeterDetailView? _detail; - private MeterPeriodView? _periods; private bool _notFound; + private bool _detailFailed; private int? _loadedId; + + /// The page's analysis state (scope: this meter). + private AnalysisQuery _query = AnalysisQuery.Default(AnalysisDefaults.History); + + /// The query the analysis was last requested for; a tab change or an action drop never reloads it (D-46). + private AnalysisQuery? _analysisRequested; + + private string _activeTab = MeterLinks.TabAnalysis; private string? _appliedTab; + private bool _tabApplied; + + /// Bumped whenever the meter's data changed, so the open tab reloads. + private int _version; + private string? _pendingAction; private SourcePreset? _pendingSource; private bool _droppingAction; private bool _refreshBeforeAction; - private int _tabIndex; - private List _sources = []; - private List _endpoints = []; - private bool _sourceOpen; - private SourceEdit _sourceEdit = new(); - private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; + + private ManualReadingDialog? _reading; + private MeterSourceDialog? _source; private MeterEventDialog? _eventDialog; private MeterEditor? _editor; - private bool _readingOpen; - private bool _readingSaving; - private readonly ReadingEntry _entry = new(); - private LocalTimeEntry _readingWhen = new(TimeZoneInfo.Utc); - private TimeZoneInfo _tz = TimeZoneInfo.Utc; + private AnalysisDefaults Defaults => MeterAnalysisLoader.DefaultsFor(Id); - /// - /// A reading typed before the user detoured into recording a swap. It is handed back to the - /// reading dialog once the swap is saved, so the detour costs no retyping. - /// - private (string Text, DateTimeOffset? At)? _resumeReading; + private bool TakesReadings => _detail is not null && MeterEventRules.TakesReadings(_detail.Mode); - /// Phone-dialpad order, ending in the row the thumb reaches last: separator, zero, backspace. - private static readonly string[] Keypad = ["7", "8", "9", "4", "5", "6", "1", "2", "3", ",", "0", "⌫"]; + private bool IsUnstarted => + _detail is { HasReadings: false, HasEvents: false, SourceCount: 0, IsVirtual: false }; - /// - /// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because - /// decimal is a C# keyword and Razor would read the required @ escape in an - /// attribute as a transition. - /// - private const InputMode DecimalKeyboard = InputMode.@decimal; - - protected override void OnInitialized() - { - _tz = LocalTimeEntry.Resolve(Options.Value.TimeZone); - _readingWhen = new LocalTimeEntry(_tz); - } + private int ActiveIndex => _detail is null ? 0 : MeterLinks.PanelIndex(_activeTab, _detail.Mode); protected override async Task OnParametersSetAsync() { - // Only a different meter reloads. The query string changes too — a deep link into a tab, or - // the action being dropped once consumed — and neither should blank and refetch the page. - var freshLoad = _loadedId != Id; - if (freshLoad) + var id = Id; + + // Only a different meter reloads the page. The query string changes too — a deep link into a tab, the action + // being dropped once consumed, a period — and none of those should blank and refetch it. + var fresh = _loadedId != id; + if (fresh) { + _loadedId = id; _detail = null; - _periods = null; _notFound = false; - _readingOpen = false; - _resumeReading = null; - // Per-meter view state: another meter opens on its first tab, and an action meant for the - // previous meter (say, a stale link to one that no longer exists) must not fire on this one. - _tabIndex = 0; + _detailFailed = false; + + // Per-meter view state: another meter opens on the tab its link names, shows none of the previous meter's + // figures, and an action meant for the previous meter must not fire on this one. + _analysis.Clear(); + _analysisRequested = null; + _activeTab = MeterLinks.TabAnalysis; + _appliedTab = null; + _tabApplied = false; _pendingAction = null; _pendingSource = null; _droppingAction = false; _refreshBeforeAction = false; - _detail = await Details.GetAsync(Id); - _notFound = _detail is null; - if (_detail is not null) - { - _periods = await Periods.GetAsync(Id); - await LoadSourcesAsync(); - } - - _loadedId = Id; + await LoadDetailAsync(id); } - // A link's tab wins when it changes, or when the link also carries an action; otherwise the tab - // the user clicked since is kept, including when the action is dropped from the address. - if (Tab is not null - && (!string.Equals(Tab, _appliedTab, StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(Action))) + if (id != Id || _detail is not { } detail || detail.Id != id) { - _tabIndex = MeterLinks.TabIndex(Tab); + return; + } + + _query = MeterAnalysisLoader.ForMeter(AnalysisQuery.Parse(Nav.Uri, Defaults), id); + + // A link's tab wins when it changes, or when the link also carries an action; otherwise the tab the user + // clicked since is kept, including when the action is dropped from the address. + if (!_tabApplied || !string.Equals(Tab, _appliedTab, StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(Action)) + { + _activeTab = MeterLinks.ResolveTab(Tab, detail.Mode); + _tabApplied = true; } _appliedTab = Tab; @@ -747,14 +252,19 @@ else SourceParam, Enum.TryParse(SourceTypeParam, ignoreCase: true, out var sourceType) ? sourceType : null, ConnectorParam); - // Arriving on a page already showing this meter: reload first, so the dialog is prefilled - // from what is stored now rather than from when the page was opened. - _refreshBeforeAction |= !freshLoad; + + // Arriving on a page already showing this meter: reload first, so the header is current when the dialog opens. + _refreshBeforeAction |= !fresh; } else { _droppingAction = false; } + + if (_activeTab == MeterLinks.TabAnalysis) + { + await EnsureAnalysisAsync(); + } } /// @@ -797,7 +307,7 @@ else if (_refreshBeforeAction) { _refreshBeforeAction = false; - await ReloadAsync(); + await ReloadDetailAsync(); if (_detail is null) { StateHasChanged(); @@ -808,13 +318,13 @@ else switch (action.ToLowerInvariant()) { case MeterLinks.ActionReading when TakesReadings: - OpenReading(); + await OpenReadingAsync(); break; case MeterLinks.ActionEdit: - await _editor!.OpenAsync(Id); + await OpenEditorAsync(); break; - case MeterLinks.ActionSource: - OpenSourceFromLink(sourcePreset); + case MeterLinks.ActionSource when !_detail.IsVirtual: + await _source!.OpenFromLinkAsync(sourcePreset?.SourceId, sourcePreset?.Type, sourcePreset?.ConnectorId); break; default: if (MeterLinks.EventFor(action) is { } type && MeterEventRules.CanRecord(_detail.Mode, type)) @@ -828,255 +338,161 @@ else StateHasChanged(); } - private bool TakesReadings => _detail is not null && MeterEventRules.TakesReadings(_detail.Mode); - - private bool IsUnstarted => - _detail is { ReadingCount: 0, Events.Count: 0 } && _sources.Count == 0 && _detail.Mode != MeterMode.Virtual; - - private string EventsHint => _detail?.Mode switch + /// + /// Loads the meter's identity. Sequenced: fast meter-to-meter navigation cancels the previous load, and an answer + /// for a meter the page has left is dropped (brief §8). + /// + private async Task LoadDetailAsync(int id) { - MeterMode.ConsumableBalance => S.MeterDetail_EventsHintTank, - MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter => S.MeterDetail_EventsHintRegister, - _ => S.MeterDetail_EventsHintNote, - }; + var ticket = _detailLoads.Next(); + MeterDetailView? detail; + try + { + detail = await Details.GetAsync(id, ticket.Token); + } + catch (OperationCanceledException) when (ticket.Token.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + if (_detailLoads.IsCurrent(ticket) && id == Id) + { + Logger.LogError(ex, "Loading meter {MeterId} failed", id); + _detailFailed = true; + } - private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz); + return; + } - private string IdentityLine() + if (!_detailLoads.IsCurrent(ticket) || id != Id) + { + return; + } + + _detail = detail; + _notFound = detail is null; + _detailFailed = false; + } + + private async Task RetryDetailAsync() + { + _detailFailed = false; + _loadedId = null; + await OnParametersSetAsync(); + } + + /// Reloads the identity in place (the tabs stay; a meter that vanished shows "not found"). + private async Task ReloadDetailAsync() + { + await LoadDetailAsync(Id); + if (_detail is { } detail) + { + // A mode change (edited into a virtual meter, say) can take the open tab away. + _activeTab = MeterLinks.ResolveTab(_activeTab, detail.Mode); + } + } + + /// Loads the analysis for the current query unless it was already requested for it (D-46). + private async Task EnsureAnalysisAsync() + { + if (_detail is not { } detail || _query == _analysisRequested) + { + return; + } + + var id = detail.Id; + var query = _query; + _analysisRequested = query; + var loader = new MeterAnalysisLoader(Periods, Reader, Costs, Details); + await _analysisLoads.RunAsync(_analysis, token => loader.LoadAsync(id, query, Clock.Now, token), Logger); + } + + private async Task ReloadAnalysisAsync() + { + _analysisRequested = null; + await EnsureAnalysisAsync(); + } + + /// After any change to the meter's data: the identity, the open tab and — when shown — the analysis. + private async Task RefreshAsync() + { + _version++; + await ReloadDetailAsync(); + _analysisRequested = null; + if (_activeTab == MeterLinks.TabAnalysis) + { + await EnsureAnalysisAsync(); + } + else + { + // Stale for its next showing: the analysis tab reloads when it is opened again. + _analysis.Clear(); + } + + StateHasChanged(); + } + + /// A tab click: remembered in the address (replace, D-46) — never a reload of the analysis or an action. + private async Task OnTabIndexChanged(int index) { if (_detail is null) { - return string.Empty; + return; } - var parts = new List(3); - if (!string.IsNullOrWhiteSpace(_detail.SerialNumber)) - { - parts.Add(Loc.F(S.MeterDetail_SerialValue, _detail.SerialNumber)); - } - - if (!string.IsNullOrWhiteSpace(_detail.Location)) - { - parts.Add(_detail.Location); - } - - var device = string.Join(' ', new[] { _detail.Manufacturer, _detail.Model }.Where(s => !string.IsNullOrWhiteSpace(s))); - if (device.Length > 0) - { - parts.Add(device); - } - - return string.Join(" · ", parts); - } - - private string ScopeText(TariffRow tariff) => tariff.Scope switch - { - TariffScope.Meter => S.MeterDetail_ScopeThisMeter, - TariffScope.EnergyType => $"{tariff.Scope.Display()}: {_detail?.EnergyType}", - _ => tariff.Scope.Display(), - }; - - private static string PrevNewText(EventRow e) => (e.PrevValue, e.NewValue) switch - { - (null, null) => "—", - ({ } prev, var next) => $"{Format.Number(prev, 2)} → {Format.Number(next ?? 0, 2)}", - (null, { } next) => $"→ {Format.Number(next, 2)}", - }; - - /// - /// "+12%" / "−4%" against the previous period. Less is better for consumption and worse for - /// generation, so colour is left to the caller's context rather than hardcoded green/red here. - /// - private static string ChangeText(double? change) - { - if (change is not { } c) - { - return S.MeterDetail_NoBasisYet; - } - - return Math.Abs(c) < 0.005 - ? S.MeterDetail_AboutTheSame - : $"{(c > 0 ? "+" : "−")}{Format.Number(Math.Abs(c) * 100, 0)}%"; - } - - private static string BarStyle(double amount, IReadOnlyList history) - { - var peak = history.Max(h => Math.Abs(h.Amount)); - var fraction = peak < 1e-9 ? 0 : Math.Abs(amount) / peak; - // Floor at 2% so a month with a little usage is still visibly distinct from an empty one. - var height = amount == 0 ? 0 : Math.Max(2, fraction * 100); - return $"width:100%; height:{height.ToString("0.#", CultureInfo.InvariantCulture)}%; " - + "background:var(--mud-palette-primary); border-radius:2px 2px 0 0"; - } - - private async Task ReloadAsync() - { - _detail = await Details.GetAsync(Id); - _notFound = _detail is null; - _periods = _detail is null ? null : await Periods.GetAsync(Id); - await LoadSourcesAsync(); - } - - private void OpenReading() - { - if (_detail is null || !TakesReadings) + var tabs = MeterLinks.VisibleTabs(_detail.Mode); + if (index < 0 || index >= tabs.Count || tabs[index] == _activeTab) { return; } - _resumeReading = null; - _readingWhen.SetNow(); - // Prefilling the last reading is what makes this quick standing at the meter: a register only - // moves in its final digits, so backspace-and-retype beats keying six digits from scratch. - // Falls back to the configured baseline while the meter has no readings at all. - _entry.Prefill(_detail.LastReadingValue ?? _detail.InitialBaseline); - _readingOpen = true; - } - - private void OnReadingTyped(string? value) => _entry.SetText(value); - - private void PressKey(string key) - { - switch (key) + var key = tabs[index]; + _activeTab = key; + _appliedTab = key; + Nav.NavigateTo(Nav.GetUriWithQueryParameter("tab", key), replace: true); + if (key == MeterLinks.TabAnalysis) { - case "⌫": - _entry.Backspace(); - break; - case ",": - _entry.AppendSeparator(); - break; - default: - _entry.AppendDigit(key[0]); - break; + await EnsureAnalysisAsync(); } } - private string LastReadingCaption() + /// A toolbar choice: written into the address, replacing the history entry (D-46). + private void ReplaceQuery(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults); + + private static string TabLabel(string key) => key switch { - if (_detail is not { } detail) - { - return string.Empty; - } + MeterLinks.TabAnalysis => S.MeterDetail_TabAnalysis, + MeterLinks.TabReadings => S.MeterDetail_TabReadings, + MeterLinks.TabNormalized => S.MeterDetail_TabNormalized, + MeterLinks.TabEvents => S.MeterDetail_TabEvents, + MeterLinks.TabTariffs => S.MeterDetail_TabTariffs, + MeterLinks.TabSources => S.MeterDetail_TabSources, + _ => S.MeterDetail_TabCalculation, + }; - return detail is { LastReadingValue: { } value, LastReadingTime: { } time } - ? Loc.F(S.MeterDetail_LastReadingCaption, - Format.Number(value, 2), detail.Unit, Local(time).ToString("yyyy-MM-dd HH:mm")) - : Loc.F(S.MeterDetail_NoReadingsPrefill, Format.Number(detail.InitialBaseline, 2), detail.Unit); - } - - private DateTimeOffset? EnteredUtc => _readingWhen.Utc; - - private bool IsMonotonic => _detail is not null && MeterEventRules.IsMonotonic(_detail.Mode); - - private bool IsBackdated => EnteredUtc is { } entered && _detail?.LastReadingTime is { } last && entered < last; - - // A minute of slack so "now" never trips the future warning on a slow round trip. - private bool IsFuture => EnteredUtc is { } entered && entered > DateTimeOffset.UtcNow.AddMinutes(1); - - private double? ChangeSinceLast => - !IsBackdated && _entry.Value is { } value && _detail?.LastReadingValue is { } last ? value - last : null; - - /// - /// A swap or reset recorded after the latest reading and no later than the entered time: it - /// explains a lower value, exactly as the ingestion guard sees it. - /// - private bool BoundaryExplainsDecrease => - EnteredUtc is { } entered && _detail is { LastReadingTime: { } last } - && _detail.Events.Any(e => MeterEventRules.IsRegisterBoundary(e.Type) && e.Time > last && e.Time <= entered); - - /// - /// Mirrors the ingestion guard closely enough to warn before saving rather than after. The - /// service compares against the reading immediately before the entered time; this page only - /// holds the latest one, so a backdated entry gets no verdict rather than a wrong one. - /// - private bool WouldBeRejected => - IsMonotonic && !IsBackdated && !BoundaryExplainsDecrease && _entry.Value is { } value - && _detail?.LastReadingValue is { } last && value < last; - - /// - /// Whether saving would overwrite a reading the page already lists. Bounded to the loaded rows, - /// so it is a heads-up rather than a guarantee — the save reports what actually happened. - /// - private bool ReplacesRecentReading => - EnteredUtc is { } entered && _detail is not null && _detail.RecentReadings.Any(r => r.Time == entered); - - /// - /// The reading being replaced is the new register's start value a swap wrote. Replacing it with - /// the real value at that instant is correct — the swap still anchors the maths — so say that - /// instead of the generic "replaces a reading", which reads like a warning. - /// - private bool ReplacesSwapStart => - EnteredUtc is { } entered && _detail is not null - && _detail.RecentReadings.Any(r => r.Time == entered && (r.Flags & (ReadingFlags.MeterSwap | ReadingFlags.CounterReset)) != 0); - - private bool CanSaveReading => - !_readingSaving && _entry.Value is not null && _readingWhen.WallClock is not null && !_readingWhen.IsSkipped; - - private string ChangeSinceText(double change) => - Math.Abs(change) < 1e-9 - ? S.MeterDetail_NoChangeSinceLast - : Loc.F(S.MeterDetail_ChangeSinceLast, - $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)}", _detail?.Unit); - - private async Task SaveReadingAsync() + private async Task OpenReadingAsync() { - if (_detail is null || _entry.Value is not { } value || EnteredUtc is not { } utc) + if (TakesReadings && _reading is not null) { - return; - } - - _readingSaving = true; - try - { - // A scope per operation: IngestionService holds a scoped DbContext, and a Blazor circuit - // long outlives the unit of work a single save should share one with. - await using var scope = Scopes.CreateAsyncScope(); - var ingestion = scope.ServiceProvider.GetRequiredService(); - var outcome = await ingestion.IngestByMeterAsync( - Id, utc, value, renormalize: true, quality: ReadingQuality.Manual); - - switch (outcome) - { - case IngestionOutcome.Written: - Snackbar.Add(Loc.F(S.MeterDetail_ReadingSaved, Format.Number(value, 2), _detail.Unit), Severity.Success); - break; - case IngestionOutcome.Updated: - Snackbar.Add(Loc.F(S.MeterDetail_ReadingReplaced, Format.Number(value, 2), _detail.Unit), Severity.Success); - break; - case IngestionOutcome.RejectedDecrease: - // Leave the dialog open with the value still on screen, and offer the fix right on - // the message: recording a swap or reset is a decision, not a retry. - Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error, config => - { - config.Action = S.MeterDetail_RecordSwap; - config.ActionColor = Color.Inherit; - config.OnClick = _ => InvokeAsync(() => SwitchToEventAsync(MeterEventType.MeterSwap)); - }); - return; - default: - Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error); - return; - } - - _readingOpen = false; - _resumeReading = null; - await ReloadAsync(); - } - finally - { - _readingSaving = false; + await _reading.OpenAsync(); } } - /// - /// From the reading dialog into the swap/reset dialog, at the time the reading was being entered — - /// the latest the swap can have happened. The typed value is kept and handed back afterwards. - /// - private async Task SwitchToEventAsync(MeterEventType type) + private async Task OpenEditorAsync() { - _resumeReading = (_entry.Text, EnteredUtc); - _readingOpen = false; - await OpenEventAsync(type, EnteredUtc, keepResume: true); + if (_editor is not null) + { + await _editor.OpenAsync(Id); + } + } + + private async Task OpenSourceAsync(MeterSource? source) + { + if (_source is not null) + { + await _source.OpenAsync(source); + } } private async Task OpenEventAsync(MeterEventType type, DateTimeOffset? at = null, bool keepResume = false) @@ -1086,396 +502,47 @@ else return; } - // Opened from the reading dialog, the user returns to that dialog afterwards, so leave the tab - // alone; opened on its own, show the list the new event is about to appear in. + // Opened on its own, the event dialog replaces a reading still waiting for a swap it was detoured to. if (!keepResume) { - _resumeReading = null; - _tabIndex = MeterLinks.TabIndex(MeterLinks.TabEvents); + _reading?.DropResume(); } await _eventDialog.OpenAsync(type, at); } + /// From the reading dialog into the swap/reset dialog, at the time the reading was being entered. + private Task OnSwitchToEventAsync((MeterEventType Type, DateTimeOffset? At) request) => + OpenEventAsync(request.Type, request.At, keepResume: true); + private async Task OnEventSavedAsync(MeterEventType type) { - await ReloadAsync(); + await RefreshAsync(); // Back to the reading that prompted the swap, with the typed digits still there. - if (_resumeReading is { } resume && MeterEventRules.IsRegisterBoundary(type) && _detail is not null) + if (_reading is { HasPendingResume: true } reading && MeterEventRules.IsRegisterBoundary(type) && _detail is not null) { - _resumeReading = null; - _entry.SetText(resume.Text); - if (resume.At is { } at) - { - _readingWhen.Set(at); - } - else - { - _readingWhen.SetNow(); - } - - _tabIndex = MeterLinks.TabIndex(MeterLinks.TabReadings); - _readingOpen = true; + await reading.ResumeAsync(); } } - private void OnEventCancelled() + private async Task OnEventCancelledAsync() { // Abandoning the swap returns to the reading as it was, rather than silently losing it. - if (_resumeReading is { } resume) + if (_reading is { HasPendingResume: true } reading) { - _resumeReading = null; - _entry.SetText(resume.Text); - if (resume.At is { } at) - { - _readingWhen.Set(at); - } - - _readingOpen = true; + await reading.ResumeAsync(); } } - private async Task OnMeterSavedAsync((int MeterId, bool Created) saved) => await ReloadAsync(); + private async Task OnMeterSavedAsync((int MeterId, bool Created) saved) => await RefreshAsync(); - private async Task DeleteReadingAsync(ReadingRow reading) - { - if (_detail is null - || !await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteReadingTitle, - Loc.F(S.MeterDetail_DeleteReadingConfirm, Format.Number(reading.Value, 2), _detail.Unit, Local(reading.Time).ToString("yyyy-MM-dd HH:mm")))) - { - return; - } - - await RunServiceAsync( - service => service.DeleteManualReadingAsync(Id, reading.Time), S.MeterDetail_ReadingDeleted, "Deleting a manual reading"); - } - - private async Task DeleteEventAsync(EventRow meterEvent) - { - if (_detail is null) - { - return; - } - - var message = MeterEventRules.IsRegisterBoundary(meterEvent.Type) - ? Loc.F(S.MeterDetail_DeleteBoundaryConfirm, meterEvent.Type.Display(), Local(meterEvent.Time).ToString("yyyy-MM-dd HH:mm")) - : Loc.F(S.MeterDetail_DeleteEventConfirm, meterEvent.Type.Display(), Local(meterEvent.Time).ToString("yyyy-MM-dd HH:mm")); - if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteEvent, message)) - { - return; - } - - await RunServiceAsync( - service => service.DeleteEventAsync(Id, meterEvent.Id), S.MeterDetail_EventDeleted, "Deleting an event"); - } - - /// - /// Runs one event-service operation in its own scope and reports the outcome. A failure has already - /// been rolled back by the service's transaction, so it is reported rather than allowed to end the - /// circuit. - /// - private async Task RunServiceAsync(Func> operation, string successText, string what) - { - try - { - await using var scope = Scopes.CreateAsyncScope(); - var result = await operation(scope.ServiceProvider.GetRequiredService()); - Snackbar.Add(result.Succeeded ? successText : MeterEventText.Problem(result.Problem), - result.Succeeded ? Severity.Success : Severity.Error); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - Logger.LogError(ex, "{What} on meter {MeterId} failed", what, Id); - Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error); - } - - await ReloadAsync(); - } - - private async Task LoadSourcesAsync() - { - await using var db = await DbFactory.CreateDbContextAsync(); - _sources = await db.MeterSources.AsNoTracking().Where(s => s.MeterId == Id).OrderBy(s => s.Priority).ToListAsync(); - _endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync(); - } - - private static string SourceTarget(MeterSource s) - { - var config = SourceConfig.Parse(s.Config); - return s.SourceType == SourceType.HomeAssistant - ? config.EntityId ?? "—" - : config.Topic ?? "—"; - } - - private void OpenNewSource() - { - _tabIndex = MeterLinks.TabIndex(MeterLinks.TabSources); - OpenSource(null); - } - - /// - /// Opens the source dialog a link asked for — typically the way back from the connector page, with - /// the source it left, its type and the connector just saved for it. - /// - private void OpenSourceFromLink(SourcePreset? preset) - { - _tabIndex = MeterLinks.TabIndex(MeterLinks.TabSources); - OpenSource(preset?.SourceId is { } sourceId ? _sources.FirstOrDefault(s => s.Id == sourceId) : null); - if (preset is null) - { - return; - } - - // Back from the connector page: everything typed before the detour comes back with the dialog. A - // link that names a source type is only ever that way back; other links open the dialog fresh. - if (preset.Type is not null && Drafts.TryTake(SourceDraftKey(preset.SourceId), out var draft)) - { - draft.Id = _sourceEdit.Id; - _sourceEdit = draft; - } - - var connector = preset.ConnectorId is { } connectorId ? _endpoints.FirstOrDefault(e => e.Id == connectorId) : null; - if ((preset.Type ?? (connector is null ? null : SourceRouting.DefaultSourceFor(connector.Type))) is { } type - && type != _sourceEdit.SourceType) - { - OnSourceTypeChanged(type); - } - - // Only a connector that can serve the source: a disabled or mismatched one would be refused on save. - if (connector is { IsEnabled: true } && SourceRouting.Serves(connector.Type, _sourceEdit.SourceType)) - { - _sourceEdit.EndpointId = connector.Id; - } - } - - /// The source being edited, or null for a new one — what a detour to the connector page returns to. - private int? SourceIdOrNull => _sourceEdit.Id == 0 ? null : _sourceEdit.Id; - - private string SourceDraftKey(int? sourceId) => - $"meter:{Id.ToString(CultureInfo.InvariantCulture)}:source:{sourceId?.ToString(CultureInfo.InvariantCulture) ?? "new"}"; - - private void CancelSource() - { - _sourceOpen = false; - Drafts.Discard(SourceDraftKey(SourceIdOrNull)); - } - - /// - /// Leaving the page with the source dialog open — the connector links in it do exactly that — keeps - /// what was typed for the way back. Disposal runs after every input already sent has been applied. - /// public void Dispose() { - if (_sourceOpen && _loadedId is { } meterId && meterId == Id) - { - Drafts.Save(SourceDraftKey(SourceIdOrNull), _sourceEdit.Clone()); - } - } - - private void OpenSource(MeterSource? source) - { - if (source is null) - { - _sourceEdit = new SourceEdit(); - OnSourceTypeChanged(_sourceEdit.SourceType); - } - else - { - var config = SourceConfig.Parse(source.Config); - _sourceEdit = new SourceEdit - { - Id = source.Id, - SourceType = source.SourceType, - EndpointId = source.EndpointId, - ValueKind = source.ValueKind, - Scale = source.Scale, - Offset = source.Offset, - Priority = source.Priority, - IsEnabled = source.IsEnabled, - EntityId = config.EntityId, - Attribute = config.Attribute, - PollMinutes = config.PollMinutes, - Topic = config.Topic, - Path = config.Path, - TimePath = config.TimePath, - }; - } - _sourceOpen = true; - } - - private async Task SaveSourceAsync() - { - // A live source without a matching connector has no connection details and would silently - // never ingest, so refuse it here rather than letting it look configured. - if (SourceRouting.RequiredEndpoint(_sourceEdit.SourceType) is { } needed) - { - var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId); - if (selected is null) - { - Snackbar.Add(Loc.F(S.MeterDetail_PickConnector, needed.Display(), _sourceEdit.SourceType.Display()), Severity.Error); - return; - } - - if (selected.Type != needed) - { - Snackbar.Add( - Loc.F(S.MeterDetail_ConnectorTypeMismatch, selected.Name, selected.Type.Display(), _sourceEdit.SourceType.Display(), needed.Display()), - Severity.Error); - return; - } - - if (!selected.IsEnabled) - { - Snackbar.Add(Loc.F(S.MeterDetail_ConnectorDisabled, selected.Name), Severity.Error); - return; - } - } - else - { - _sourceEdit.EndpointId = null; - } - - var config = new SourceConfig - { - EntityId = Trim(_sourceEdit.EntityId), - Attribute = Trim(_sourceEdit.Attribute), - PollMinutes = _sourceEdit.PollMinutes, - Topic = Trim(_sourceEdit.Topic), - Path = Trim(_sourceEdit.Path), - TimePath = Trim(_sourceEdit.TimePath), - }; - var configJson = System.Text.Json.JsonSerializer.Serialize(config, - new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); - - await using var db = await DbFactory.CreateDbContextAsync(); - if (_sourceEdit.Id == 0) - { - db.MeterSources.Add(new MeterSource - { - MeterId = Id, - SourceType = _sourceEdit.SourceType, - EndpointId = _sourceEdit.EndpointId, - Config = configJson, - ValueKind = _sourceEdit.ValueKind, - Scale = _sourceEdit.Scale, - Offset = _sourceEdit.Offset, - Priority = _sourceEdit.Priority, - IsEnabled = _sourceEdit.IsEnabled, - }); - } - else - { - var existing = await db.MeterSources.FirstAsync(s => s.Id == _sourceEdit.Id); - existing.SourceType = _sourceEdit.SourceType; - existing.EndpointId = _sourceEdit.EndpointId; - existing.Config = configJson; - existing.ValueKind = _sourceEdit.ValueKind; - existing.Scale = _sourceEdit.Scale; - existing.Offset = _sourceEdit.Offset; - existing.Priority = _sourceEdit.Priority; - existing.IsEnabled = _sourceEdit.IsEnabled; - } - - await db.SaveChangesAsync(); - _sourceOpen = false; - Drafts.Discard(SourceDraftKey(SourceIdOrNull)); - Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success); - await LoadSourcesAsync(); - } - - private async Task DeleteSourceAsync(MeterSource source) - { - if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteSourceTitle, - Loc.F(S.MeterDetail_DeleteSourceConfirm, source.SourceType.Display()))) - { - return; - } - - await using var db = await DbFactory.CreateDbContextAsync(); - await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync(); - Snackbar.Add(S.MeterDetail_SourceDeleted, Severity.Success); - await LoadSourcesAsync(); - } - - private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - - // Only enabled connectors can ingest: both MQTT and HA workers filter on IsEnabled, so offering - // a disabled one would produce a source that saves cleanly and then never runs. - private List ConnectorsFor(EndpointType type) => - _endpoints.Where(e => e.Type == type && e.IsEnabled).ToList(); - - /// - /// Why this source cannot ingest, or null if it can. Routing is endpoint-scoped, so an unbound - /// or mis-bound source is silently dead — and deleting a connector unlinks its sources, which - /// used to be harmless. Without this column such a source is indistinguishable from a healthy - /// one at "Enabled: yes". - /// - private string? ConnectorProblem(MeterSource source) - { - if (SourceRouting.RequiredEndpoint(source.SourceType) is not { } needed) - { - return null; - } - - var endpoint = _endpoints.FirstOrDefault(e => e.Id == source.EndpointId); - return endpoint switch - { - null => S.MeterDetail_ProblemNoConnector, - { IsEnabled: false } => Loc.F(S.MeterDetail_ProblemDisabled, endpoint.Name), - _ when endpoint.Type != needed => Loc.F(S.MeterDetail_ProblemTypeMismatch, endpoint.Name, endpoint.Type.Display(), needed.Display()), - _ => null, - }; - } - - // Changing the source type can invalidate the chosen connector (an HA connector cannot serve an - // MQTT source), so drop a selection that no longer fits rather than saving a mismatched pair. - private void OnSourceTypeChanged(SourceType sourceType) - { - _sourceEdit.SourceType = sourceType; - - var needed = SourceRouting.RequiredEndpoint(sourceType); - var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId); - if (needed is null || (selected is not null && selected.Type != needed)) - { - _sourceEdit.EndpointId = null; - } - - // Sole candidate: preselect it, so the common single-broker / single-HA setup is one click. Only - // enabled ones count — the picker offers nothing else, so a disabled pick would be invisible. - if (needed is { } kind && _sourceEdit.EndpointId is null) - { - var candidates = ConnectorsFor(kind); - if (candidates.Count == 1) - { - _sourceEdit.EndpointId = candidates[0].Id; - } - } + _detailLoads.Dispose(); + _analysisLoads.Dispose(); } /// What a link presets in the source dialog; see . private sealed record SourcePreset(int? SourceId, SourceType? Type, int? ConnectorId); - - private sealed class SourceEdit - { - public SourceEdit Clone() => (SourceEdit)MemberwiseClone(); - - public int Id { get; set; } - public SourceType SourceType { get; set; } = SourceType.HomeAssistant; - public int? EndpointId { get; set; } - public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register; - public double Scale { get; set; } = 1; - public double Offset { get; set; } - public int Priority { get; set; } - public bool IsEnabled { get; set; } = true; - public string? EntityId { get; set; } - public string? Attribute { get; set; } - public int? PollMinutes { get; set; } = 60; - public string? Topic { get; set; } - public string? Path { get; set; } - public string? TimePath { get; set; } - } - - private static RenderFragment QualityChip(ReadingQuality quality) =>@@quality.Display(); } diff --git a/src/App/Components/Pages/MeterPage/AfterNowChip.razor b/src/App/Components/Pages/MeterPage/AfterNowChip.razor new file mode 100644 index 0000000..0b0fb19 --- /dev/null +++ b/src/App/Components/Pages/MeterPage/AfterNowChip.razor @@ -0,0 +1,4 @@ +@* A record dated after now (D-04): the record tabs list the whole named range, so a row stamped later this month — a + current-month label, a device clock ahead — is shown, and marked in words, since it is not counted in any actual yet. *@ +@S.MeterDetail_AfterNow diff --git a/src/App/Components/Pages/MeterPage/ManualReadingDialog.razor b/src/App/Components/Pages/MeterPage/ManualReadingDialog.razor new file mode 100644 index 0000000..047d224 --- /dev/null +++ b/src/App/Components/Pages/MeterPage/ManualReadingDialog.razor @@ -0,0 +1,393 @@ +@using Microsoft.Extensions.DependencyInjection +@using MeterVault.Infrastructure.Ingestion +@inject MeterDetailService Details +@inject IServiceScopeFactory Scopes +@inject ISnackbar Snackbar +@inject InstanceClock Clock +@inject ILogger Logger + +@* Big touch targets and tabular digits for the manual-reading dialog: it is used standing at a + meter on a phone, where the default input sizes are fiddly. *@ + + + + + @Loc.F(S.MeterDetail_AddReadingTitle, MeterName) + + + @LastReadingCaption() + + + + @* Fixed height, and above the keypad on purpose. The verdict on a value has to be visible + while it is being typed — the keypad pushes anything below it off a phone screen — but + anything that grows or shrinks here would move the keys out from under the user's + thumb mid-entry. So the slot is always the same size whether or not it says anything. *@ +
+ + @(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {Unit}" : S.MeterDetail_EnterValue) + + @if (Verdict.WouldBeRejected) + { + @* Names the likely cause, but deliberately is not a button: this line shows for most of + an ordinary entry (every prefix of 12351 is below 12345) and sits just above the + keypad, so a slightly high tap on the top keys would leave the reading mid-entry. The + swap and reset buttons are in the alert below and on the rejection message. *@ + @S.MeterDetail_SwappedOrResetHint + } + else if (Verdict.ChangeSincePrevious is { } change) + { + @ChangeSinceText(change) + } +
+ +
+ @foreach (var key in Keypad) + { + var pressed = key; + @pressed + } +
+ +
+ + + @S.Common_Now +
+ @Loc.F(S.MeterDetail_LocalTimeIn, Zone.Id) + + @* Everything below here can reflow freely: the dialog's buttons sit outside this scroll + area, so nothing the user is aiming at moves. *@ + @if (_when.IsSkipped) + { + + @Loc.F(S.MeterDetail_SkippedTime, Zone.Id) + + } + @if (Verdict is { WouldBeRejected: true, Previous: { } previous }) + { + + @Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(previous.Value, 2), Unit) +
+ @S.MeterDetail_RecordSwap + @S.MeterDetail_RecordReset +
+
+ } + @if (Verdict.ReplacesRegisterStart) + { + @S.MeterDetail_ReplaceSwapStartNotice + } + else if (Verdict.ReplacesReading) + { + + @S.MeterDetail_ReplaceNotice + + } + @if (Verdict.IsFuture) + { + @S.MeterDetail_FutureTime + } + else if (Verdict.IsBackdated) + { + + @S.MeterDetail_BackdatedNotice + + } +
+ + @S.Common_Cancel + + @(_saving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading) + + +
+ +@code { + /// The meter the reading is for. + [Parameter, EditorRequired] + public int MeterId { get; set; } + + /// Its name, for the title (user data). + [Parameter] + public string MeterName { get; set; } = string.Empty; + + /// The raw unit of the register (readings are raw values). + [Parameter] + public string Unit { get; set; } = string.Empty; + + /// The instance zone the date and time are typed in. + [Parameter, EditorRequired] + public TimeZoneInfo Zone { get; set; } = TimeZoneInfo.Utc; + + /// Raised after a reading was stored (the meter is renormalized already). + [Parameter] + public EventCallback Saved { get; set; } + + /// + /// Raised when the user leaves for a swap or reset at the entered time; the typed value is kept for + /// . + /// + [Parameter] + public EventCallback<(MeterEventType Type, DateTimeOffset? At)> SwitchToEvent { get; set; } + + /// Phone-dialpad order, ending in the row the thumb reaches last: separator, zero, backspace. + private static readonly string[] Keypad = ["7", "8", "9", "4", "5", "6", "1", "2", "3", ",", "0", "⌫"]; + + /// + /// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because + /// decimal is a C# keyword and Razor would read the required @ escape in an + /// attribute as a transition. + /// + private const InputMode DecimalKeyboard = InputMode.@decimal; + + private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; + private readonly ReadingEntry _entry = new(); + private LocalTimeEntry _when = new(TimeZoneInfo.Utc); + private bool _open; + private bool _saving; + + /// The context for the instant the pickers show (D-50); versioned, as the pickers move faster than queries return. + private ReadingEntryContext? _context; + private int _contextVersion; + + /// + /// A reading typed before the user detoured into recording a swap. It is handed back to this dialog once the swap + /// is saved or abandoned, so the detour costs no retyping. + /// + private (string Text, DateTimeOffset? At)? _resume; + + /// True while a typed reading waits for the swap or reset the user left to record. + public bool HasPendingResume => _resume is not null; + + private ReadingEntryVerdict Verdict => ReadingEntryVerdict.Of(_context, _entry.Value, _when.Utc, Clock.Now); + + private bool CanSave => !_saving && _entry.Value is not null && _when.WallClock is not null && !_when.IsSkipped; + + protected override void OnParametersSet() + { + if (!ReferenceEquals(_when.Zone, Zone)) + { + _when = new LocalTimeEntry(Zone); + } + } + + /// + /// Opens the dialog at now, prefilled with the meter's latest reading (or its baseline while it has none) — a register + /// only moves in its final digits, so backspace-and-retype beats keying six digits from scratch. + /// + public async Task OpenAsync() + { + _resume = null; + _entry.Clear(); + _when.Set(Clock.Now); + await LoadContextAsync(); + _entry.Prefill(_context?.Latest?.Value ?? _context?.InitialBaseline ?? 0); + _open = true; + StateHasChanged(); + } + + /// Reopens the dialog with the reading typed before the swap/reset detour, at its time. + public async Task ResumeAsync() + { + if (_resume is not { } resume) + { + return; + } + + _resume = null; + _entry.SetText(resume.Text); + if (resume.At is { } at) + { + _when.Set(at); + } + else + { + _when.Set(Clock.Now); + } + + await LoadContextAsync(); + _open = true; + StateHasChanged(); + } + + /// Forgets a typed reading waiting for a detour (the user went on to something else). + public void DropResume() => _resume = null; + + /// Closes the dialog without saving. + public void Close() + { + _open = false; + StateHasChanged(); + } + + private void OnVisibleChanged(bool visible) + { + if (!visible) + { + _open = false; + } + } + + private void OnReadingTyped(string? value) => _entry.SetText(value); + + private void PressKey(string key) + { + switch (key) + { + case "⌫": + _entry.Backspace(); + break; + case ",": + _entry.AppendSeparator(); + break; + default: + _entry.AppendDigit(key[0]); + break; + } + } + + private async Task OnDateChangedAsync(DateTime? date) + { + _when.Date = date; + await LoadContextAsync(); + } + + private async Task OnTimeChangedAsync(TimeSpan? time) + { + _when.TimeOfDay = time; + await LoadContextAsync(); + } + + private async Task SetNowAsync() + { + _when.Set(Clock.Now); + await LoadContextAsync(); + } + + /// + /// Reads what the entry at the chosen instant is judged against (D-50). Versioned: a late answer for an earlier time + /// must not overwrite a newer one. A failure leaves no verdict, and the save still goes through the guard. + /// + private async Task LoadContextAsync() + { + if (_when.Utc is not { } utc) + { + return; + } + + var version = ++_contextVersion; + try + { + var context = await Details.GetReadingEntryContextAsync(MeterId, utc); + if (version == _contextVersion) + { + _context = context; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning(ex, "Reading the entry context of meter {MeterId} failed", MeterId); + } + } + + private string LastReadingCaption() => _context switch + { + { Latest: { } latest } => Loc.F(S.MeterDetail_LastReadingCaption, + Format.Number(latest.Value, 2), Unit, _when.Local(latest.Time).ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)), + { } context => Loc.F(S.MeterDetail_NoReadingsPrefill, Format.Number(context.InitialBaseline, 2), Unit), + _ => string.Empty, + }; + + private string ChangeSinceText(double change) => + Math.Abs(change) < 1e-9 + ? S.MeterDetail_NoChangeSinceLast + : Loc.F(S.MeterDetail_ChangeSinceLast, $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)}", Unit); + + private async Task SaveAsync() + { + if (_entry.Value is not { } value || _when.Utc is not { } utc) + { + return; + } + + _saving = true; + try + { + // A scope per operation: IngestionService holds a scoped DbContext, and a Blazor circuit + // long outlives the unit of work a single save should share one with. + await using var scope = Scopes.CreateAsyncScope(); + var ingestion = scope.ServiceProvider.GetRequiredService(); + IngestionOutcome outcome; + try + { + outcome = await ingestion.IngestByMeterAsync(MeterId, utc, value, renormalize: true, quality: ReadingQuality.Manual); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogError(ex, "Saving a manual reading on meter {MeterId} failed", MeterId); + Snackbar.Add(S.MeterDetail_ReadingFailed, Severity.Error); + return; + } + + switch (outcome) + { + case IngestionOutcome.Written: + Snackbar.Add(Loc.F(S.MeterDetail_ReadingSaved, Format.Number(value, 2), Unit), Severity.Success); + break; + case IngestionOutcome.Updated: + Snackbar.Add(Loc.F(S.MeterDetail_ReadingReplaced, Format.Number(value, 2), Unit), Severity.Success); + break; + case IngestionOutcome.RejectedDecrease: + // Leave the dialog open with the value still on screen, and offer the fix right on + // the message: recording a swap or reset is a decision, not a retry. + Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error, config => + { + config.Action = S.MeterDetail_RecordSwap; + config.ActionColor = Color.Inherit; + config.OnClick = _ => InvokeAsync(() => SwitchToEventAsync(MeterEventType.MeterSwap)); + }); + await LoadContextAsync(); + return; + default: + Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error); + return; + } + + _open = false; + _resume = null; + await Saved.InvokeAsync(); + } + finally + { + _saving = false; + } + } + + /// + /// From this dialog into the swap/reset dialog, at the time the reading was being entered — the latest the swap can + /// have happened. The typed value is kept and handed back afterwards (). + /// + private async Task SwitchToEventAsync(MeterEventType type) + { + _resume = (_entry.Text, _when.Utc); + _open = false; + await SwitchToEvent.InvokeAsync((type, _resume.Value.At)); + } +} diff --git a/src/App/Components/Pages/MeterPage/MeterAnalysisTab.razor b/src/App/Components/Pages/MeterPage/MeterAnalysisTab.razor new file mode 100644 index 0000000..7d74bf4 --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterAnalysisTab.razor @@ -0,0 +1,237 @@ +@inject NavigationManager Nav + +@* The meter's Analysis tab (brief §7.2): the shared period toolbar with CSV export, the period's quantity (normalized + unit, D-20) and cost (with its rule named, or why there is none), the projection kept apart from the actual (D-09), + the full-size chart with the comparison overlay, the accessible table, a click on a bucket drilling down (D-51), and + what qualifies the figures: coverage, resolution, opening balance, rows after now, freshness, and the events and + tariff changes inside the range. A virtual meter gets the same from its formula, with its sources' contributions. *@ + + + + + @if (r.MeterId == Detail.Id) + { + var s = r.Series; + @if (s is null) + { + @Loc.F(S.MeterDetail_NotFound, Detail.Id) + } + else if (r.Quantities.Refusal != AnalysisRefusal.None) + { + @* The toolbar above says why (too many points) and offers the coarser bucket. *@ + } + else + { + + + @if (s.IsPending) + { + + } + else if (s.Total.Status == BucketStatus.Invalid) + { + + @Loc.F(S.MeterDetail_CalculationNotEvaluable, (s.Virtual?.Status ?? VirtualMeterStatus.Invalid).Display()) +
+ @S.MeterDetail_EditCalculation + @S.MeterDetail_ShowCalculation +
+
+ } + else if (r.Quantities.NotYetOccurred || s.Total.Status == BucketStatus.Missing) + { + + @if (s.Availability is null) + { + @NextStepText +
+ @if (MeterEventRules.TakesReadings(Detail.Mode)) + { + @S.MeterDetail_AddFirstReading + @S.MeterDetail_ConnectSource + } + else if (Detail.Mode == MeterMode.ConsumableBalance) + { + @S.MeterDetail_RecordTankLevel + } + else if (Detail.IsVirtual) + { + @S.MeterDetail_ShowCalculation + } +
+ } +
+ @if (Detail.IsVirtual) + { + + } + } + else + { + + + + @if (r.Projection is { } projection) + { + + } + + + @if (r.Cost is { } cost) + { + + @if (r.IsCosted) + { + + } + else + { + + @S.MeterDetail_CostTitle +
+ @MeterCostRule.None.Display() +
+ @((cost.Meter?.NotCosted ?? MeterNotCostedReason.None).Display()) +
+ } +
+ } +
+ + + + @* A click leads somewhere or is not offered (D-51): the chart is clickable, the table has its drill column and + the hint shows only when some bucket opens something. *@ + var drills = r.Quantities.Plan.Buckets.Any(b => DrillHref(b, r, s) is not null); + + @if (drills) + { + + @DrillHint(s) + } + else + { + + } + + + + + @if (Detail.IsVirtual) + { + @S.MeterDetail_SourcesOfCalculation + + } + } + + + } + } +
+ +@code { + /// The meter. + [Parameter, EditorRequired] + public MeterDetailView Detail { get; set; } = null!; + + /// The page's analysis state. + [Parameter, EditorRequired] + public AnalysisQuery Query { get; set; } = null!; + + /// The page's analysis load (owned by the page, so switching tabs never reloads it, D-46). + [Parameter, EditorRequired] + public LoadState State { get; set; } = null!; + + /// A toolbar choice: the page writes it into its address (replace). + [Parameter] + public EventCallback OnQueryChanged { get; set; } + + [Parameter] + public EventCallback OnRetry { get; set; } + + [Parameter] + public EventCallback OnAddReading { get; set; } + + [Parameter] + public EventCallback OnRecordEvent { get; set; } + + /// Opens the shared meter editor (install date, calculation). + [Parameter] + public EventCallback OnEdit { get; set; } + + private AnalysisDefaults Defaults => MeterAnalysisLoader.DefaultsFor(Detail.Id); + + /// The committed value when it is this meter's. + private MeterAnalysisView? Current => State.Value is { } value && value.MeterId == Detail.Id ? value : null; + + private string ExportHref => AnalysisLinks.Export(MeterAnalysisLoader.ForMeter(Query, Detail.Id)); + + private string? ComparisonCaption => Query.Comparison.Kind == ComparisonKind.None ? null : Query.Comparison.Display(); + + private string NextStepText => Detail.Mode switch + { + MeterMode.ConsumableBalance => S.MeterDetail_NextStepTank, + MeterMode.Virtual => S.MeterDetail_NextStepVirtual, + _ => S.MeterDetail_NextStepCounter, + }; + + /// + /// The readers' problems for the attention list. Rows recorded after now are explained with their dates and amount in + /// the coverage section below, so they are not listed twice. + /// + private static IEnumerable ProblemsOf(MeterAnalysisView view) => + view.Quantities.Problems.Concat(view.Cost?.QuantityProblems ?? []) + .Where(p => p.Kind != AnalysisProblemKind.RecordedAfterNow); + + private string CostRuleText(Infrastructure.Costing.CostAnalysis cost) => + Loc.F(S.MeterDetail_CostRule, (cost.Meter?.Rule ?? MeterCostRule.None).Display()); + + private static string ChartTitle(MeterAnalysisView view, AnalysisSeries series) => + view.ShowsCost + ? Loc.F(S.MeterDetail_CostOf, AnalysisChartSeries.NameOf(series)) + : Loc.F(S.MeterDetail_ChartTitle, series.Kind.Display(), AnalysisChartSeries.NameOf(series)); + + private string DrillHint(AnalysisSeries series) => + Detail.IsVirtual ? S.MeterDetail_DrillHintVirtual : S.MeterDetail_DrillHint; + + private string? LatestHref(AnalysisSeries series) => + AnalysisNavigation.LatestData(Query, series.Availability) is { } latest ? MeterLinks.Analysis(Detail.Id, latest) : null; + + /// + /// Where a bucket leads (D-51, ): the next finer size the data resolves; otherwise a physical + /// meter's records of the bucket, or a virtual meter's own analysis over just that bucket, whose source contributions + /// link on to each source's records — never a dead end. + /// + private string? DrillHref(AnalysisBucket bucket, MeterAnalysisView view, AnalysisSeries series) => + MeterDrill.Href(Detail.Id, Detail.IsVirtual, Query, view.Period, bucket, series.Resolution); + + /// A chart click drills down, pushing a history entry so Back returns to the range it came from (D-46). + private void Drill(AnalysisBucket bucket, MeterAnalysisView view, AnalysisSeries series) + { + if (DrillHref(bucket, view, series) is { } href) + { + Nav.NavigateTo(href); + } + } + + /// The buckets are finer than the data: apply the interval that shows it (the page replaces its address). + private Task UseBucketAsync(BucketSize size) => OnQueryChanged.InvokeAsync(Query.WithBucket(size)); +} diff --git a/src/App/Components/Pages/MeterPage/MeterCalculationTab.razor b/src/App/Components/Pages/MeterPage/MeterCalculationTab.razor new file mode 100644 index 0000000..8d6bf28 --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterCalculationTab.razor @@ -0,0 +1,231 @@ +@implements IDisposable +@inject MeterDetailService Details +@inject ILogger Logger + +@* A virtual meter's calculation (brief §5.1, D-25 – D-31): its status (valid, legacy — confirm, needs configuration, + invalid), the formula with each m token beside the meter's name, what it yields and in which unit, its cost rule, + the meters it reads (linked to their own analysis), and every validation problem with the meters involved — plus the + one action that fixes them: Edit calculation, in the shared meter editor. It replaces Sources (a calculation has no + ingest), and register details never show here. *@ + + + @if (calc.MeterId == Detail.Id) + { +
+ + @calc.Status.Display() + + @StatusText(calc.Status) + + @S.MeterDetail_EditCalculation + +
+ + + @S.Contributions_Formula + @if (FormulaText.Split(calc.Expression) is { Count: > 0 } segments) + { +
+ @foreach (var segment in segments) + { + @if (segment.MeterId is { } id) + { + + @if (calc.NameOf(id) is { } name) + { + @name + } + else + { + @S.MeterDetail_UnknownMeter + } + @segment.Text + + } + else + { + @segment.Text + } + } +
+ } + else + { + @S.MeterDetail_NoFormula + } + +
+
@S.MeterDetail_Result
+
@Loc.F(S.MeterDetail_QuantityIn, calc.Kind.Display(), calc.Unit)
+
@S.MeterDetail_CostRuleLabel
+
+ @calc.CostRule.Display() + @if (calc.CostRuleProblem is not null && calc.DeclaredCostRule is { } declared && declared != calc.CostRule) + { +
@Loc.F(S.MeterDetail_CostRuleIgnored, declared.Display())
+ } +
+
+
+ + @if (calc.Problems.Count > 0 || calc.CostRuleProblem is not null || calc.Legacy is { NeedsConfiguration: true }) + { + @S.MeterDetail_CalcProblems +
    + @if (calc.Legacy is { NeedsConfiguration: true } legacy) + { +
  • +
  • + } + @foreach (var problem in calc.Problems.Concat(calc.CostRuleProblem is { } p ? [p] : [])) + { +
  • +
  • + } +
+ } + + @S.MeterDetail_ReferencedMeters + @if (calc.Sources.Count == 0) + { + @S.MeterDetail_NoReferencedMeters + } + else + { +
+ + + + @S.Common_Name + @S.Common_Mode + @S.MeterDetail_Kind + @S.Common_Unit + + + + @foreach (var source in calc.Sources) + { + + + @if (source.Exists) + { + @source.Name + @($"m{source.MeterId}") + } + else + { + @S.MeterDetail_UnknownMeter + @($"m{source.MeterId}") + } + + @(source.Exists ? source.Mode.Display() : Format.Unknown) + @(source.Exists ? source.Kind.Display() : Format.Unknown) + @(source.Exists ? source.Unit : Format.Unknown) + + } + + +
+ @if (calc.Sources.Any(s => s.IsVirtual) && calc.PhysicalLeaves.Count > 0) + { + + @S.MeterDetail_ReadsPhysical + @for (var i = 0; i < calc.PhysicalLeaves.Count; i++) + { + var leaf = calc.PhysicalLeaves[i]; + @(i > 0 ? ", " : " ")@leaf.Name + } + + } + } + + @S.MeterDetail_CalculationVsFlow + } +
+ +@code { + [Parameter, EditorRequired] + public MeterDetailView Detail { get; set; } = null!; + + /// The page's analysis state: links to the source meters carry its period. + [Parameter] + public AnalysisQuery? Query { get; set; } + + /// Bumped by the page whenever the meter (or its definition) changed. + [Parameter] + public int Version { get; set; } + + /// Opens the shared meter editor on this meter. + [Parameter] + public EventCallback OnEdit { get; set; } + + private readonly LoadSequencer _loads = new(); + private readonly LoadState _state = new(); + private (int MeterId, int Version)? _loadedFor; + + protected override async Task OnParametersSetAsync() + { + var key = (Detail.Id, Version); + if (_loadedFor == key) + { + return; + } + + if (_loadedFor?.MeterId != Detail.Id) + { + _state.Clear(); + } + + _loadedFor = key; + await LoadAsync(); + } + + private Task LoadAsync() + { + var meterId = Detail.Id; + return _loads.RunAsync(_state, async token => + await Details.GetCalculationAsync(meterId, token) + ?? throw new InvalidOperationException($"Meter {meterId} is not a virtual meter."), Logger); + } + + private static string StatusText(VirtualMeterStatus status) => status switch + { + VirtualMeterStatus.Valid => S.MeterDetail_CalcStatusValid, + VirtualMeterStatus.Legacy => S.MeterDetail_CalcStatusLegacy, + VirtualMeterStatus.NeedsConfiguration => S.MeterDetail_CalcStatusNeedsConfiguration, + VirtualMeterStatus.Malformed => S.MeterDetail_CalcStatusMalformed, + _ => S.MeterDetail_CalcStatusInvalid, + }; + + private static string LegacyText(LegacyDerivation legacy) => legacy.Outcome switch + { + LegacyDerivationOutcome.NoSources => S.MeterDetail_LegacyNoSources, + LegacyDerivationOutcome.UnknownSource => S.MeterDetail_LegacyUnknownSource, + LegacyDerivationOutcome.SourceNeedsConfiguration => S.MeterDetail_LegacySourceNeedsConfiguration, + LegacyDerivationOutcome.NotAdditive => S.MeterDetail_LegacyNotAdditive, + LegacyDerivationOutcome.MixedUnits => S.MeterDetail_LegacyMixed, + LegacyDerivationOutcome.MixedKinds => S.MeterDetail_LegacyMixed, + _ => S.MeterDetail_CalcStatusNeedsConfiguration, + }; + + /// + /// One validation finding in words — the same sentence the attention list and give it, with + /// the units or kinds involved; a syntax error in the editor's words, with its position. The meters involved follow + /// it as links (). + /// + private static string ProblemText(VirtualProblem problem) => + problem is { Kind: VirtualProblemKind.Syntax, SyntaxError: { } error } + ? MeterVault.App.MeterEditing.MeterEditorText.FormulaError(error) + : AttentionItems.VirtualReasonWithoutMeters(problem); + + public void Dispose() => _loads.Dispose(); +} diff --git a/src/App/Components/Pages/MeterPage/MeterCalculationTab.razor.css b/src/App/Components/Pages/MeterPage/MeterCalculationTab.razor.css new file mode 100644 index 0000000..924872e --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterCalculationTab.razor.css @@ -0,0 +1,42 @@ +.mv-calc-facts { + display: grid; + grid-template-columns: minmax(7rem, max-content) 1fr; + gap: .35rem 1.5rem; + margin: 0; +} + +.mv-calc-facts dt { + color: var(--mud-palette-text-secondary); + font-size: .875rem; +} + +.mv-calc-facts dd { + margin: 0; + min-width: 0; +} + +.mv-calc-problems { + list-style: none; + margin: 0; + padding: 0; +} + +.mv-calc-problems li { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: .25rem .5rem; + padding: .25rem 0; + color: var(--mud-palette-text-primary); +} + +@media (max-width: 599.98px) { + .mv-calc-facts { + grid-template-columns: 1fr; + gap: .1rem; + } + + .mv-calc-facts dd { + margin-bottom: .4rem; + } +} diff --git a/src/App/Components/Pages/MeterPage/MeterCoverageNote.razor b/src/App/Components/Pages/MeterPage/MeterCoverageNote.razor new file mode 100644 index 0000000..6733bce --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterCoverageNote.razor @@ -0,0 +1,232 @@ +@* What qualifies the meter's figures (brief §4.3, §7.2, D-13 – D-19): the resolution its data has, the dates it covers, + an opening balance of unknown start (with the offer to set an install date), rows dated after now that are not + counted yet, how current the data is, what the normalized quantity assumes, and — as context for the chart — the + events and tariff changes inside the range. Worded, never a bare percentage the metadata cannot support. *@ + +
+ @S.MeterDetail_QualityTitle + +
+
+
@S.MeterDetail_Resolution
+
+ @if (Series?.Resolution is { } resolution) + { + @resolution.Display() + @if (resolution >= ResolutionClass.Month) + { +
@(Detail.IsVirtual ? S.MeterDetail_ResolutionMonthlyHintVirtual : S.MeterDetail_ResolutionMonthlyHint)
+ } + } + else + { + @Format.Unknown + } +
+
+
+
@S.MeterDetail_DataRange
+
+ @if (Series?.Availability is { } available) + { + @Format.DateRange(available.FirstDay, available.LastDay) + } + else + { + @S.MeterDetail_NoDataAtAll + } +
+
+
+
@S.MeterDetail_Freshness
+
+ @if (Series?.Freshness is { State: not FreshnessState.NoData } freshness) + { + @freshness.State.Display() + @if (freshness.LastActivity is { } last) + { +
@Loc.F(S.MeterDetail_LastActivity, Format.Date(PeriodResolver.LocalDate(last, View.Period.Zone)))
+ } + @if (freshness.State == FreshnessState.Stale && !Detail.IsVirtual) + { + @S.Attention_CheckSource + } + } + else + { + @FreshnessState.NoData.Display() + } +
+
+
+
@S.MeterDetail_Quantity
+
+ @QuantityText + @foreach (var note in NoteTexts) + { +
@note
+ } +
+
+
+ + @if (HasOpeningBalance) + { + + @Loc.F(S.MeterDetail_OpeningBalanceNote, Format.Number(Detail.InitialBaseline, 2), Detail.Unit) + @if (Detail.InstalledAt is null && !Detail.IsVirtual) + { +
+ @S.MeterDetail_SetInstallDate +
+ } +
+ } + + @foreach (var block in Series?.RecordedAfterNow ?? []) + { + + @Loc.F(S.MeterDetail_RecordedAfterNow, + NameOf(block.MeterId), + block.Rows, + Format.Quantity(block.Amount, Series!.Unit), + Format.DateRange(block.FirstDay, block.LastDay)) +
+ @S.MeterDetail_ShowRecords +
+
+ } +
+ + @if (!View.Markers.IsEmpty) + { + @S.MeterDetail_InThisPeriod +
    + @foreach (var e in View.Markers.Events) + { +
  • +
  • + } + @foreach (var t in View.Markers.TariffChanges) + { +
  • +
  • + } +
+
+ @if (View.Markers.Events.Count > 0) + { + + @(View.Markers.MoreEvents ? S.MeterDetail_AllEventsInPeriod : S.MeterDetail_GoToEvents) + + } + @if (View.Markers.TariffChanges.Count > 0) + { + @S.MeterDetail_OpenTariffs + } +
+ } +
+ +@code { + /// The committed analysis. + [Parameter, EditorRequired] + public MeterAnalysisView View { get; set; } = null!; + + [Parameter, EditorRequired] + public MeterDetailView Detail { get; set; } = null!; + + [Parameter] + public AnalysisQuery? Query { get; set; } + + /// Opens the meter editor (to set an install date). + [Parameter] + public EventCallback OnEdit { get; set; } + + [Parameter] + public string? Class { get; set; } + + private readonly string _headingId = "mv-quality-" + Guid.NewGuid().ToString("N")[..8]; + + private AnalysisSeries? Series => View.Series; + + /// A first reading booked against the baseline with an unknown start (D-14) sits in the range. + private bool HasOpeningBalance => + Series is { } s && (s.Total.Provenance.HasFlag(Provenance.OpeningBalance) || s.Values.Any(v => v.Provenance.HasFlag(Provenance.OpeningBalance))); + + private string QuantityText => Series is { } s + ? Loc.F(S.MeterDetail_QuantityIn, s.Kind.Display(), s.Unit) + : Format.Unknown; + + private IEnumerable NoteTexts + { + get + { + var notes = Series?.Notes ?? default; + if (notes.HasFlag(QuantityNotes.FixedRateEstimate)) + { + yield return S.MeterDetail_NoteFixedRate; + } + + if (notes.HasFlag(QuantityNotes.RateAssumedPerHour)) + { + yield return S.MeterDetail_NoteRatePerHour; + } + + if (notes.HasFlag(QuantityNotes.RateNotPerHour)) + { + yield return S.MeterDetail_NoteRateNotPerHour; + } + + if (notes.HasFlag(QuantityNotes.RegisterNotInHours)) + { + yield return S.MeterDetail_NoteRegisterNotInHours; + } + + if (notes.HasFlag(QuantityNotes.UndeclaredResult)) + { + yield return S.MeterDetail_NoteUndeclaredResult; + } + } + } + + private string NameOf(int meterId) => + meterId == Detail.Id ? Detail.Name + : Series?.Contributions.SelectMany(Flatten).FirstOrDefault(c => c.MeterId == meterId)?.Name is { Length: > 0 } name ? name + : Loc.F(S.Attention_MeterFallback, meterId); + + private static IEnumerable Flatten(SeriesContribution contribution) => + contribution.Nested.SelectMany(Flatten).Prepend(contribution); + + /// The normalized records of the days holding rows after now, on the meter that holds them. + private string RecordsLink(RecordedAfterNow block) + { + var target = Query is null || !PeriodResolver.IsValidCustomRange(block.FirstDay, block.LastDay) + ? Query + : Query.WithCustomRange(block.FirstDay, block.LastDay); + return MeterLinks.Detail(block.MeterId, MeterLinks.TabNormalized, null, target); + } + + private static string EventDetail(EventRow e) => e.Type switch + { + MeterEventType.Delivery or MeterEventType.TankLevel when e.Amount is { } amount => $": {Format.Number(amount, 1)} {e.Unit}".TrimEnd(), + MeterEventType.MeterSwap or MeterEventType.CounterReset when e.PrevValue is not null || e.NewValue is not null => + $": {(e.PrevValue is { } p ? Format.Number(p, 2) : Format.Unknown)} → {(e.NewValue is { } n ? Format.Number(n, 2) : Format.Unknown)}", + _ when !string.IsNullOrWhiteSpace(e.Notes) => ": " + e.Notes, + _ => string.Empty, + }; + + private string ScopeText(TariffRow tariff) => tariff.Scope switch + { + TariffScope.Meter => S.MeterDetail_ScopeThisMeter, + TariffScope.EnergyType => Detail.EnergyType, + _ => tariff.Scope.Display(), + }; +} diff --git a/src/App/Components/Pages/MeterPage/MeterCoverageNote.razor.css b/src/App/Components/Pages/MeterPage/MeterCoverageNote.razor.css new file mode 100644 index 0000000..fb6a55f --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterCoverageNote.razor.css @@ -0,0 +1,53 @@ +/* The quality facts as a two-column list on wide screens, stacked on a phone. Palette variables only. */ +.mv-facts { + display: grid; + grid-template-columns: minmax(8rem, max-content) 1fr; + gap: .5rem 1.5rem; + margin: 0; +} + +.mv-facts__row { + display: contents; +} + +.mv-facts dt { + color: var(--mud-palette-text-secondary); + font-size: .875rem; +} + +.mv-facts dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 599.98px) { + .mv-facts { + grid-template-columns: 1fr; + gap: .15rem; + } + + .mv-facts dd { + margin-bottom: .5rem; + } +} + +.mv-markers { + list-style: none; + margin: 0 0 .5rem; + padding: 0; +} + +.mv-markers li { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: .25rem .5rem; + padding: .2rem 0; +} + +.mv-markers__date { + color: var(--mud-palette-text-secondary); + font-variant-numeric: tabular-nums; + min-width: 8.5rem; +} diff --git a/src/App/Components/Pages/MeterPage/MeterEventsTab.razor b/src/App/Components/Pages/MeterPage/MeterEventsTab.razor new file mode 100644 index 0000000..52a118c --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterEventsTab.razor @@ -0,0 +1,144 @@ +@inherits RecordTabBase +@using Microsoft.Extensions.DependencyInjection +@using MeterVault.Infrastructure.Ingestion +@inject IServiceScopeFactory Scopes +@inject IDialogService DialogService +@inject ISnackbar Snackbar + +@* A meter's events (brief §7.2, D-50): swaps and resets that keep a register's history continuous, a tank's levels and + deliveries, notes. Recorded through MeterEventService (the dialog), deleted there too — an imported one only by + reverting its import. Paged and filtered by the page period. *@ + +
+ @EventsHint + + @foreach (var type in MeterEventRules.RecordableFor(Detail.Mode)) + { + var chosen = type; + @($"{chosen.Display()}…") + } + +
+ + + + + @if (view.MeterId == Detail.Id) + { + @if (view.Page.Rows.Count == 0) + { + @(Detail.HasEvents ? S.MeterDetail_NoEventsInPeriod : S.MeterDetail_NoEvents) + @if (Detail.HasEvents && view.Range.IsBounded) + { + @S.MeterDetail_ShowAllDates + } + } + else + { + @Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id) +
+ + + + @S.MeterDetail_Time + @S.Common_Type + @S.Common_Amount + @S.MeterDetail_PrevNew + @S.MeterDetail_Notes + @S.Common_Actions + + + + @foreach (var e in view.Page.Rows) + { + + + @Local(e.Time) + @if (IsAfterNow(e.Time)) + { + + } + + + +
+ + } + } +
+ +@code { + /// Opens the event dialog for a type (the page owns it). + [Parameter] + public EventCallback OnRecordEvent { get; set; } + + private string EventsHint => Detail.Mode switch + { + MeterMode.ConsumableBalance => S.MeterDetail_EventsHintTank, + MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter => S.MeterDetail_EventsHintRegister, + _ => S.MeterDetail_EventsHintNote, + }; + + protected override Task> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) => + Details.GetEventsAsync(meterId, range, cursor, cancellationToken); + + private static string PrevNewText(EventRow e) => (e.PrevValue, e.NewValue) switch + { + (null, null) => Format.Unknown, + ({ } prev, var next) => $"{Format.Number(prev, 2)} → {(next is { } n ? Format.Number(n, 2) : Format.Unknown)}", + (null, { } next) => $"→ {Format.Number(next, 2)}", + }; + + private async Task DeleteAsync(EventRow meterEvent) + { + var message = MeterEventRules.IsRegisterBoundary(meterEvent.Type) + ? Loc.F(S.MeterDetail_DeleteBoundaryConfirm, meterEvent.Type.Display(), Local(meterEvent.Time)) + : Loc.F(S.MeterDetail_DeleteEventConfirm, meterEvent.Type.Display(), Local(meterEvent.Time)); + if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteEvent, message)) + { + return; + } + + try + { + await using var scope = Scopes.CreateAsyncScope(); + var result = await scope.ServiceProvider.GetRequiredService().DeleteEventAsync(Detail.Id, meterEvent.Id); + Snackbar.Add(result.Succeeded ? S.MeterDetail_EventDeleted : MeterEventText.Problem(result.Problem), + result.Succeeded ? Severity.Success : Severity.Error); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The service's transaction has rolled back; report rather than end the circuit. + LoggerFactory.CreateLogger().LogError(ex, "Deleting an event on meter {MeterId} failed", Detail.Id); + Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error); + } + + await OnChanged.InvokeAsync(); + } +} diff --git a/src/App/Components/Pages/MeterPage/MeterHeader.razor b/src/App/Components/Pages/MeterPage/MeterHeader.razor new file mode 100644 index 0000000..776b03c --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterHeader.razor @@ -0,0 +1,106 @@ +@* The meter page's header (brief §7.2, D-48): Overview → energy type → meter, carrying the period; the name; chips for + the energy type (its analysis), the mode and "retired"; and the meter's own actions, so entering a reading, recording + a swap or fixing a setting never depends on finding the right tab first. The tab bar follows directly below. *@ + + + + + + + + @Detail.EnergyType + + @Detail.Mode.Display() + @if (IsRetired) + { + @RetiredText + } + + + @if (MeterEventRules.TakesReadings(Detail.Mode)) + { + + @S.MeterDetail_AddReading + + } + else if (Detail.Mode == MeterMode.ConsumableBalance) + { + + @S.MeterDetail_RecordTankLevel + + } + + @foreach (var type in MeterEventRules.RecordableFor(Detail.Mode)) + { + var chosen = type; + @($"{chosen.Display()}…") + } + + + + + + + @ChildContent + + + +@code { + /// The meter. + [Parameter, EditorRequired] + public MeterDetailView Detail { get; set; } = null!; + + /// The page's analysis state: breadcrumbs and the type chip carry its period. + [Parameter] + public AnalysisQuery? Query { get; set; } + + /// Today in the instance zone: a retire date on or before it makes the meter retired. + [Parameter] + public DateOnly Today { get; set; } + + [Parameter] + public EventCallback OnAddReading { get; set; } + + [Parameter] + public EventCallback OnRecordEvent { get; set; } + + [Parameter] + public EventCallback OnEdit { get; set; } + + /// Notices that belong to the header (a tank to set up, a first step). + [Parameter] + public RenderFragment? ChildContent { get; set; } + + private bool IsRetired => !Detail.IsActive || Detail.RetiredAt is { } retired && retired <= Today; + + private string RetiredText => Detail.RetiredAt is { } retired + ? Loc.F(S.MeterDetail_RetiredOn, Format.Date(retired)) + : S.MeterDetail_Retired; + + private string? IdentityLine() + { + var parts = new List(3); + if (!string.IsNullOrWhiteSpace(Detail.SerialNumber)) + { + parts.Add(Loc.F(S.MeterDetail_SerialValue, Detail.SerialNumber)); + } + + if (!string.IsNullOrWhiteSpace(Detail.Location)) + { + parts.Add(Detail.Location); + } + + var device = string.Join(' ', new[] { Detail.Manufacturer, Detail.Model }.Where(s => !string.IsNullOrWhiteSpace(s))); + if (device.Length > 0) + { + parts.Add(device); + } + + return parts.Count == 0 ? null : string.Join(" · ", parts); + } +} diff --git a/src/App/Components/Pages/MeterPage/MeterNormalizedTab.razor b/src/App/Components/Pages/MeterPage/MeterNormalizedTab.razor new file mode 100644 index 0000000..df1791e --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterNormalizedTab.razor @@ -0,0 +1,65 @@ +@inherits RecordTabBase + +@* Normalized data (brief §7.2, D-50): the consumption or generation deltas derived from the readings and events, in the + meter's normalized unit (D-20) — what every chart, total and cost is built from. Derived and reproducible: they are + rebuilt whenever a reading or event changes. A drill-down from a chart bucket the data cannot resolve lands here, + filtered to that bucket. *@ + +@Loc.F(S.MeterDetail_NormalizedIntro, Detail.NormalizedUnit) + + + + + @if (view.MeterId == Detail.Id) + { + @if (view.Page.Rows.Count == 0) + { + @(view.Range.IsBounded ? S.MeterDetail_NoNormalizedInPeriod : S.MeterDetail_NoConsumption) + @if (view.Range.IsBounded) + { + @S.MeterDetail_ShowAllDates + } + } + else + { + @Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id) +
+ + + + @S.MeterDetail_Time + @Loc.F(S.MeterDetail_AmountIn, Detail.NormalizedUnit) + @S.MeterDetail_Kind + @S.MeterDetail_Quality + + + + @foreach (var c in view.Page.Rows) + { + + + @Local(c.Time) + @if (IsAfterNow(c.Time)) + { + + } + + @Format.Number(c.Amount, 3) + @c.Kind.Display() + + + } + + +
+ + } + } +
+ +@code { + protected override Task> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) => + Details.GetConsumptionAsync(meterId, range, cursor, cancellationToken); +} diff --git a/src/App/Components/Pages/MeterPage/MeterPath.razor b/src/App/Components/Pages/MeterPage/MeterPath.razor new file mode 100644 index 0000000..ca40c18 --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterPath.razor @@ -0,0 +1,36 @@ +@* The meters a calculation finding involves — the operands of a mismatch, the path of a loop — by name, each linked to + its own page over the same period; an id that names no meter says so. *@ + +@if (Ids.Count > 0) +{ + + — + @for (var i = 0; i < Ids.Count; i++) + { + var id = Ids[i]; + @(i > 0 ? Separator : string.Empty) + @if (Calculation.NameOf(id) is { } name) + { + @name + } + else + { + @Loc.F(S.Attention_MeterFallback, id) + } + } + +} + +@code { + [Parameter, EditorRequired] + public IReadOnlyList Ids { get; set; } = []; + + [Parameter, EditorRequired] + public MeterCalculationView Calculation { get; set; } = null!; + + [Parameter] + public AnalysisQuery? Query { get; set; } + + [Parameter] + public string Separator { get; set; } = ", "; +} diff --git a/src/App/Components/Pages/MeterPage/MeterReadingsTab.razor b/src/App/Components/Pages/MeterPage/MeterReadingsTab.razor new file mode 100644 index 0000000..f6f1c28 --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterReadingsTab.razor @@ -0,0 +1,136 @@ +@inherits RecordTabBase +@using Microsoft.Extensions.DependencyInjection +@using MeterVault.Infrastructure.Ingestion +@inject IServiceScopeFactory Scopes +@inject IDialogService DialogService +@inject ISnackbar Snackbar + +@* Raw readings (brief §7.2, D-50, D-57): every value exactly as it arrived, in the register's raw unit — the audit record + everything else is derived from, never changed to fix a figure. Paged and filtered by the page period; hand-entered + ones can be deleted (the meter is recomputed). *@ + +@S.MeterDetail_ReadingsIntro + +@if (Detail.Mode == MeterMode.ConsumableBalance) +{ + @* A tank's consumption comes from level and delivery events; a reading typed here would + save cleanly and change nothing, so the tab sends the user where it counts. *@ + + @S.MeterDetail_TankUsesEvents + @S.MeterDetail_GoToEvents + +} +else +{ +
+ + @if (Detail.FirstReading is { } first && Detail.LastReading is { } last) + { + @Loc.F(S.MeterDetail_RegisterSummary, + Format.Number(first.Value, 2), Local(first.Time), Format.Number(last.Value, 2), Local(last.Time), Detail.Unit) + } + @(" " + Loc.F(S.MeterDetail_BaselineValue, Format.Number(Detail.InitialBaseline, 2))) + + + @S.MeterDetail_AddReading + +
+} + + + + + @if (view.MeterId == Detail.Id) + { + @if (view.Page.Rows.Count == 0) + { + @(Detail.HasReadings ? S.MeterDetail_NoReadingsInPeriod : S.MeterDetail_NoRawReadings) + @if (Detail.HasReadings && view.Range.IsBounded) + { + @S.MeterDetail_ShowAllDates + } + } + else + { + @Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id) +
+ + + + @S.MeterDetail_Time + @Loc.F(S.MeterDetail_ValueIn, Detail.Unit) + @S.MeterDetail_Quality + @S.MeterDetail_Flags + @S.Common_Actions + + + + @foreach (var r in view.Page.Rows) + { + + + @Local(r.Time) + @if (IsAfterNow(r.Time)) + { + + } + + @Format.Number(r.Value, 2) + + @r.Flags.Display() + + @if (r.Quality == ReadingQuality.Manual) + { + + + + } + + + } + + +
+ + } + } +
+ +@code { + /// Opens the manual-reading dialog (the page owns it). + [Parameter] + public EventCallback OnAddReading { get; set; } + + protected override Task> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) => + Details.GetReadingsAsync(meterId, range, cursor, cancellationToken); + + private async Task DeleteAsync(ReadingRow reading) + { + if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteReadingTitle, + Loc.F(S.MeterDetail_DeleteReadingConfirm, Format.Number(reading.Value, 2), Detail.Unit, Local(reading.Time)))) + { + return; + } + + try + { + await using var scope = Scopes.CreateAsyncScope(); + var result = await scope.ServiceProvider.GetRequiredService().DeleteManualReadingAsync(Detail.Id, reading.Time); + Snackbar.Add(result.Succeeded ? S.MeterDetail_ReadingDeleted : MeterEventText.Problem(result.Problem), + result.Succeeded ? Severity.Success : Severity.Error); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The service's transaction has rolled back; report rather than end the circuit. + LoggerFactory.CreateLogger().LogError(ex, "Deleting a manual reading on meter {MeterId} failed", Detail.Id); + Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error); + } + + await OnChanged.InvokeAsync(); + } +} diff --git a/src/App/Components/Pages/MeterPage/MeterSourceDialog.razor b/src/App/Components/Pages/MeterPage/MeterSourceDialog.razor new file mode 100644 index 0000000..592d66e --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterSourceDialog.razor @@ -0,0 +1,377 @@ +@using System.Globalization +@using Microsoft.EntityFrameworkCore +@using MeterVault.Infrastructure.Ingestion +@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory +@inject ISnackbar Snackbar +@inject DraftStore Drafts +@implements IDisposable + +@* A meter's ingest source: its type, the connector that serves it and where the value sits in the payload. Every way to + a missing connector is a detour that comes back here with the connector picked and everything typed restored: the + dialog saves itself to the circuit's DraftStore when the page is left while it is open. *@ + + + @(_edit.Id == 0 ? S.MeterDetail_NewSource : S.MeterDetail_EditSource) + + + + @foreach (var type in Enum.GetValues()) + { + @type.Display() + } + + @if (SourceRouting.RequiredEndpoint(_edit.SourceType) is { } needed) + { + @* Every way to a missing connector leads back here with it picked, so setting one up is a + detour rather than a dead end that loses the meter. *@ + var usable = ConnectorsFor(needed); + if (usable.Count == 0) + { + + @if (_endpoints.FirstOrDefault(e => e.Type == needed && !e.IsEnabled) is { } disabled) + { + @Loc.F(S.MeterDetail_ConnectorOnlyDisabled, disabled.Name) @S.MeterDetail_EnableConnectorLink + } + else + { + @Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) @S.MeterDetail_CreateConnectorLink @S.MeterDetail_CreateConnectorHint + } + + } + else + { + + @foreach (var e in usable) + { + @e.Name + } + +
+ @if (_edit.EndpointId is { } chosen && usable.Any(e => e.Id == chosen)) + { + @* Change the connection itself (URL, token, broker) without losing what is typed here: the dialog's + draft is kept and the connector page leads back to it. *@ + + @S.MeterDetail_EditConnectorLink + + } + + @S.MeterDetail_AnotherConnector + +
+ } + } + @if (_edit.SourceType == SourceType.HomeAssistant) + { + + + + + @S.MeterDetail_PollHint + + } + else if (_edit.SourceType is SourceType.Mqtt or SourceType.Tasmota) + { + + + + } + + @foreach (var kind in Enum.GetValues()) + { + @kind.Display() + } + +
+ + + +
+ +
+ + @S.Common_Cancel + @S.Common_Save + +
+ +@code { + /// The meter the source feeds. + [Parameter, EditorRequired] + public int MeterId { get; set; } + + /// Raised after a source was saved. + [Parameter] + public EventCallback Saved { get; set; } + + private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; + private List _endpoints = []; + private SourceEdit _edit = new(); + private bool _open; + + /// The meter the dialog was opened for: a draft is only ever saved under the meter it belongs to. + private int? _openFor; + + /// Opens the dialog for a new source, or for . + public async Task OpenAsync(MeterSource? source) + { + await LoadEndpointsAsync(); + Fill(source); + Show(); + } + + /// + /// Opens the dialog a link asked for — typically the way back from the connector page, with the source it left, its + /// type and the connector just saved for it (). + /// + public async Task OpenFromLinkAsync(int? sourceId, SourceType? type, int? connectorId) + { + await LoadEndpointsAsync(); + MeterSource? source = null; + if (sourceId is { } id) + { + await using var db = await DbFactory.CreateDbContextAsync(); + source = await db.MeterSources.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id && s.MeterId == MeterId); + } + + Fill(source); + + // Back from the connector page: everything typed before the detour comes back with the dialog. A + // link that names a source type is only ever that way back; other links open the dialog fresh. + if (type is not null && Drafts.TryTake(DraftKey(MeterId, sourceId), out var draft)) + { + draft.Id = _edit.Id; + _edit = draft; + } + + var connector = connectorId is { } cid ? _endpoints.FirstOrDefault(e => e.Id == cid) : null; + if ((type ?? (connector is null ? null : SourceRouting.DefaultSourceFor(connector.Type))) is { } wanted + && wanted != _edit.SourceType) + { + OnSourceTypeChanged(wanted); + } + + // Only a connector that can serve the source: a disabled or mismatched one would be refused on save. + if (connector is { IsEnabled: true } && SourceRouting.Serves(connector.Type, _edit.SourceType)) + { + _edit.EndpointId = connector.Id; + } + + Show(); + } + + /// + /// Leaving the page with the dialog open — the connector links in it do exactly that — keeps what was typed for the + /// way back. Disposal runs after every input already sent has been applied. + /// + public void Dispose() + { + if (_open && _openFor is { } meterId) + { + Drafts.Save(DraftKey(meterId, SourceIdOrNull), _edit.Clone()); + } + } + + /// The draft key of a meter's source dialog (a new source when is null). + public static string DraftKey(int meterId, int? sourceId) => + $"meter:{meterId.ToString(CultureInfo.InvariantCulture)}:source:{sourceId?.ToString(CultureInfo.InvariantCulture) ?? "new"}"; + + private void Show() + { + _openFor = MeterId; + _open = true; + StateHasChanged(); + } + + /// + /// The dialog closed on its own (backdrop, Escape, or the dialog provider dismissing it on navigation). Only the + /// Cancel button throws the draft away: when the page is left through a connector link, the provider dismisses the + /// dialog after has saved what was typed, and that draft is the way back. + /// + private void OnVisibleChanged(bool visible) + { + if (!visible) + { + _open = false; + } + } + + /// The source being edited, or null for a new one — what a detour to the connector page returns to. + private int? SourceIdOrNull => _edit.Id == 0 ? null : _edit.Id; + + private void Cancel() + { + _open = false; + Drafts.Discard(DraftKey(MeterId, SourceIdOrNull)); + } + + private async Task LoadEndpointsAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync(); + } + + private void Fill(MeterSource? source) + { + if (source is null) + { + _edit = new SourceEdit(); + OnSourceTypeChanged(_edit.SourceType); + return; + } + + var config = SourceConfig.Parse(source.Config); + _edit = new SourceEdit + { + Id = source.Id, + SourceType = source.SourceType, + EndpointId = source.EndpointId, + ValueKind = source.ValueKind, + Scale = source.Scale, + Offset = source.Offset, + Priority = source.Priority, + IsEnabled = source.IsEnabled, + EntityId = config.EntityId, + Attribute = config.Attribute, + PollMinutes = config.PollMinutes, + Topic = config.Topic, + Path = config.Path, + TimePath = config.TimePath, + }; + } + + private async Task SaveAsync() + { + // A live source without a matching connector has no connection details and would silently + // never ingest, so refuse it here rather than letting it look configured. + if (SourceRouting.RequiredEndpoint(_edit.SourceType) is { } needed) + { + var selected = _endpoints.FirstOrDefault(e => e.Id == _edit.EndpointId); + if (selected is null) + { + Snackbar.Add(Loc.F(S.MeterDetail_PickConnector, needed.Display(), _edit.SourceType.Display()), Severity.Error); + return; + } + + if (selected.Type != needed) + { + Snackbar.Add( + Loc.F(S.MeterDetail_ConnectorTypeMismatch, selected.Name, selected.Type.Display(), _edit.SourceType.Display(), needed.Display()), + Severity.Error); + return; + } + + if (!selected.IsEnabled) + { + Snackbar.Add(Loc.F(S.MeterDetail_ConnectorDisabled, selected.Name), Severity.Error); + return; + } + } + else + { + _edit.EndpointId = null; + } + + var config = new SourceConfig + { + EntityId = Trim(_edit.EntityId), + Attribute = Trim(_edit.Attribute), + PollMinutes = _edit.PollMinutes, + Topic = Trim(_edit.Topic), + Path = Trim(_edit.Path), + TimePath = Trim(_edit.TimePath), + }; + var configJson = System.Text.Json.JsonSerializer.Serialize(config, + new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); + + await using (var db = await DbFactory.CreateDbContextAsync()) + { + if (_edit.Id == 0) + { + db.MeterSources.Add(new MeterSource + { + MeterId = MeterId, + SourceType = _edit.SourceType, + EndpointId = _edit.EndpointId, + Config = configJson, + ValueKind = _edit.ValueKind, + Scale = _edit.Scale, + Offset = _edit.Offset, + Priority = _edit.Priority, + IsEnabled = _edit.IsEnabled, + }); + } + else + { + var existing = await db.MeterSources.FirstAsync(s => s.Id == _edit.Id); + existing.SourceType = _edit.SourceType; + existing.EndpointId = _edit.EndpointId; + existing.Config = configJson; + existing.ValueKind = _edit.ValueKind; + existing.Scale = _edit.Scale; + existing.Offset = _edit.Offset; + existing.Priority = _edit.Priority; + existing.IsEnabled = _edit.IsEnabled; + } + + await db.SaveChangesAsync(); + } + + _open = false; + Drafts.Discard(DraftKey(MeterId, SourceIdOrNull)); + Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success); + await Saved.InvokeAsync(); + } + + private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + // Only enabled connectors can ingest: both MQTT and HA workers filter on IsEnabled, so offering + // a disabled one would produce a source that saves cleanly and then never runs. + private List ConnectorsFor(EndpointType type) => + _endpoints.Where(e => e.Type == type && e.IsEnabled).ToList(); + + // Changing the source type can invalidate the chosen connector (an HA connector cannot serve an + // MQTT source), so drop a selection that no longer fits rather than saving a mismatched pair. + private void OnSourceTypeChanged(SourceType sourceType) + { + _edit.SourceType = sourceType; + + var needed = SourceRouting.RequiredEndpoint(sourceType); + var selected = _endpoints.FirstOrDefault(e => e.Id == _edit.EndpointId); + if (needed is null || (selected is not null && selected.Type != needed)) + { + _edit.EndpointId = null; + } + + // Sole candidate: preselect it, so the common single-broker / single-HA setup is one click. Only + // enabled ones count — the picker offers nothing else, so a disabled pick would be invisible. + if (needed is { } kind && _edit.EndpointId is null) + { + var candidates = ConnectorsFor(kind); + if (candidates.Count == 1) + { + _edit.EndpointId = candidates[0].Id; + } + } + } + + private sealed class SourceEdit + { + public SourceEdit Clone() => (SourceEdit)MemberwiseClone(); + + public int Id { get; set; } + public SourceType SourceType { get; set; } = SourceType.HomeAssistant; + public int? EndpointId { get; set; } + public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register; + public double Scale { get; set; } = 1; + public double Offset { get; set; } + public int Priority { get; set; } + public bool IsEnabled { get; set; } = true; + public string? EntityId { get; set; } + public string? Attribute { get; set; } + public int? PollMinutes { get; set; } = 60; + public string? Topic { get; set; } + public string? Path { get; set; } + public string? TimePath { get; set; } + } +} diff --git a/src/App/Components/Pages/MeterPage/MeterSourcesTab.razor b/src/App/Components/Pages/MeterPage/MeterSourcesTab.razor new file mode 100644 index 0000000..0971051 --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterSourcesTab.razor @@ -0,0 +1,193 @@ +@using Microsoft.EntityFrameworkCore +@using MeterVault.Infrastructure.Ingestion +@implements IDisposable +@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject ILogger Logger + +@* How a physical meter is fed (brief §3.2 "Meter → Sources → Edit connection"): its ingest sources with their connector, + last value and status — and a source that can never ingest (no connector, a disabled one, the wrong kind) says so in + its row instead of looking healthy. Editing opens the page's source dialog, whose connector detour keeps the draft. *@ + +
+ @S.MeterDetail_SourcesIntro + + @S.MeterDetail_AddSource + +
+ + + @if (view.Sources.Count == 0) + { + @S.MeterDetail_NoSources + } + else + { +
+ + + + @S.Common_Type + @S.Common_Target + @S.MeterDetail_Connector + @S.Common_Enabled + @S.Common_LastSeen + @S.MeterDetail_LastValue + @S.Common_Status + @S.Common_Actions + + + + @foreach (var s in view.Sources) + { + + @s.SourceType.Display() + @SourceTarget(s) + + @{ var problem = ConnectorProblem(view, s); } + @if (problem is null) + { + @* Brief §3.2 "Meter → Sources → Edit connection": the connector in use opens for editing, and the + connector page leads back here (MeterLinks.EditConnector). *@ + @if (view.Endpoints.FirstOrDefault(e => e.Id == s.EndpointId) is { } endpoint) + { + @endpoint.Name + } + else + { + @Format.Unknown + } + } + else + { + + @problem + + } + + @(s.IsEnabled ? S.MeterDetail_Yes : S.MeterDetail_No) + @(s.LastSeenAt is { } seen ? TimeZoneInfo.ConvertTime(seen, Zone).ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture) : Format.Unknown) + @(s.LastValue is { } v ? Format.Number(v, 2) : Format.Unknown) + @(s.LastStatus ?? Format.Unknown) + + + + + + } + + +
+ } +
+ +@code { + [Parameter, EditorRequired] + public MeterDetailView Detail { get; set; } = null!; + + /// The instance zone the last-seen times are shown in. + [Parameter, EditorRequired] + public TimeZoneInfo Zone { get; set; } = TimeZoneInfo.Utc; + + /// Bumped by the page whenever the meter or its sources changed. + [Parameter] + public int Version { get; set; } + + /// Opens the page's source dialog: a new source (null) or an existing one. + [Parameter] + public EventCallback OnEdit { get; set; } + + /// Raised after a source was deleted. + [Parameter] + public EventCallback OnChanged { get; set; } + + private readonly LoadSequencer _loads = new(); + private readonly LoadState _state = new(); + private (int MeterId, int Version)? _loadedFor; + + private sealed record SourcesView(IReadOnlyList Sources, IReadOnlyList Endpoints); + + protected override async Task OnParametersSetAsync() + { + var key = (Detail.Id, Version); + if (_loadedFor == key) + { + return; + } + + if (_loadedFor?.MeterId != Detail.Id) + { + _state.Clear(); + } + + _loadedFor = key; + await LoadAsync(); + } + + private Task LoadAsync() + { + var meterId = Detail.Id; + return _loads.RunAsync(_state, async token => + { + await using var db = await DbFactory.CreateDbContextAsync(token); + var sources = await db.MeterSources.AsNoTracking().Where(s => s.MeterId == meterId).OrderBy(s => s.Priority).ToListAsync(token); + var endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync(token); + return new SourcesView(sources, endpoints); + }, Logger); + } + + private static string SourceTarget(MeterSource s) + { + var config = SourceConfig.Parse(s.Config); + return s.SourceType == SourceType.HomeAssistant + ? config.EntityId ?? Format.Unknown + : config.Topic ?? Format.Unknown; + } + + /// + /// Why this source cannot ingest, or null if it can. Routing is endpoint-scoped, so an unbound + /// or mis-bound source is silently dead — and deleting a connector unlinks its sources, which + /// used to be harmless. Without this column such a source is indistinguishable from a healthy + /// one at "Enabled: yes". + /// + private static string? ConnectorProblem(SourcesView view, MeterSource source) + { + if (SourceRouting.RequiredEndpoint(source.SourceType) is not { } needed) + { + return null; + } + + var endpoint = view.Endpoints.FirstOrDefault(e => e.Id == source.EndpointId); + return endpoint switch + { + null => S.MeterDetail_ProblemNoConnector, + { IsEnabled: false } => Loc.F(S.MeterDetail_ProblemDisabled, endpoint.Name), + _ when endpoint.Type != needed => Loc.F(S.MeterDetail_ProblemTypeMismatch, endpoint.Name, endpoint.Type.Display(), needed.Display()), + _ => null, + }; + } + + private async Task DeleteAsync(MeterSource source) + { + if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteSourceTitle, + Loc.F(S.MeterDetail_DeleteSourceConfirm, source.SourceType.Display()))) + { + return; + } + + await using (var db = await DbFactory.CreateDbContextAsync()) + { + await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync(); + } + + Snackbar.Add(S.MeterDetail_SourceDeleted, Severity.Success); + await OnChanged.InvokeAsync(); + } + + public void Dispose() => _loads.Dispose(); +} diff --git a/src/App/Components/Pages/MeterPage/MeterTariffsTab.razor b/src/App/Components/Pages/MeterPage/MeterTariffsTab.razor new file mode 100644 index 0000000..7b10eba --- /dev/null +++ b/src/App/Components/Pages/MeterPage/MeterTariffsTab.razor @@ -0,0 +1,134 @@ +@using MeterVault.App.TariffEditing +@implements IDisposable +@inject MeterDetailService Details +@inject InstanceClock Clock +@inject ILogger Logger + +@* The prices that can apply to this meter (brief §7.2): its own, its energy type's and the global ones, each with its + validity. A meter price wins over the type's, the type's over the global one. "Add tariff for this meter" opens the + tariff editor prefilled for exactly this meter (D-52). *@ + +
+ @S.MeterDetail_TariffsIntro +
+ + @S.MeterDetail_AddMeterTariff + + @S.MeterDetail_ManageTariffs +
+
+ + + @if (tariffs.Count == 0) + { + @S.MeterDetail_NoTariffs + } + else + { +
+ + + + @S.Common_Scope + @S.MeterDetail_Component + @S.Common_Value + @S.Common_Unit + @S.MeterDetail_From + @S.MeterDetail_To + + + + @foreach (var line in Lines(tariffs, Clock.Today)) + { + var t = line.Row; + + @ScopeText(t) + + @t.Component.Display() + @if (line.AppliesNow) + { + @S.MeterDetail_TariffCurrent + } + + @Format.Number(t.Value, 4) + @t.Unit + @Format.Date(t.ValidFrom) + @(line.EffectiveTo is { } to ? Format.Date(to) : S.MeterDetail_TariffOpenEnd) + + } + + +
+ @S.MeterDetail_TariffsCurrentNote + } +
+ +@code { + [Parameter, EditorRequired] + public MeterDetailView Detail { get; set; } = null!; + + /// Bumped by the page whenever the meter changed. + [Parameter] + public int Version { get; set; } + + private readonly LoadSequencer _loads = new(); + private readonly LoadState> _state = new(); + private (int MeterId, int Version)? _loadedFor; + + /// What a new price for this meter usually is: the credit for an export meter, the unit price otherwise. + private TariffComponent DefaultComponent => Detail.Kind == QuantityKind.Export ? TariffComponent.FeedIn : TariffComponent.UnitPrice; + + protected override async Task OnParametersSetAsync() + { + var key = (Detail.Id, Version); + if (_loadedFor == key) + { + return; + } + + if (_loadedFor?.MeterId != Detail.Id) + { + _state.Clear(); + } + + _loadedFor = key; + await LoadAsync(); + } + + private Task LoadAsync() + { + var meterId = Detail.Id; + return _loads.RunAsync(_state, token => Details.GetTariffsAsync(meterId, token), Logger); + } + + /// A tariff with the day it effectively ends, and whether it is the price that applies today. + private sealed record TariffLine(TariffRow Row, DateOnly? EffectiveTo, bool AppliesNow); + + /// + /// Each tariff runs until its own end or the day before the next one of the same scope and component starts; per + /// component the one valid today with the narrowest scope applies (meter, then energy type, then global). + /// + private static List Lines(IReadOnlyList tariffs, DateOnly today) + { + var ends = TariffValidity.EffectiveEnds( + tariffs.Select(t => new TariffSpan(t.Id, t.Scope, t.ScopeId, t.Component, t.ValidFrom, t.ValidTo))); + + bool Valid(TariffRow t) => t.ValidFrom <= today && (ends[t.Id] is not { } end || end >= today); + var current = tariffs.Where(Valid) + .GroupBy(t => t.Component) + .Select(g => g.OrderBy(t => t.Scope switch { TariffScope.Meter => 0, TariffScope.EnergyType => 1, _ => 2 }).First().Id) + .ToHashSet(); + return [.. tariffs.Select(t => new TariffLine(t, ends[t.Id], current.Contains(t.Id)))]; + } + + private string ScopeText(TariffRow tariff) => tariff.Scope switch + { + TariffScope.Meter => S.MeterDetail_ScopeThisMeter, + TariffScope.EnergyType => $"{tariff.Scope.Display()}: {Detail.EnergyType}", + _ => tariff.Scope.Display(), + }; + + public void Dispose() => _loads.Dispose(); +} diff --git a/src/App/Components/Pages/MeterPage/QualityChip.razor b/src/App/Components/Pages/MeterPage/QualityChip.razor new file mode 100644 index 0000000..5f59d00 --- /dev/null +++ b/src/App/Components/Pages/MeterPage/QualityChip.razor @@ -0,0 +1,8 @@ +@* A row's quality in words, with a colour that only repeats what the word says. *@ +@Quality.Display() + +@code { + [Parameter, EditorRequired] + public ReadingQuality Quality { get; set; } +} diff --git a/src/App/Components/Pages/MeterPage/RecordPagerBar.razor b/src/App/Components/Pages/MeterPage/RecordPagerBar.razor new file mode 100644 index 0000000..d618ca9 --- /dev/null +++ b/src/App/Components/Pages/MeterPage/RecordPagerBar.razor @@ -0,0 +1,59 @@ +@* The paging row under a record table (D-50): which rows are shown out of how many the filter holds, and the way to + the newest, newer and older pages. Keyset pages, so "Newer" returns to exactly the page it came from. *@ + + + +@code { + [Parameter, EditorRequired] + public RecordPager Pager { get; set; } = null!; + + /// Rows on the page shown. + [Parameter] + public int Count { get; set; } + + /// Rows the filter holds (up to the count cap). + [Parameter] + public int Total { get; set; } + + [Parameter] + public bool TotalIsCapped { get; set; } + + /// True when an older page exists. + [Parameter] + public bool HasOlder { get; set; } + + [Parameter] + public EventCallback OnNewest { get; set; } + + [Parameter] + public EventCallback OnNewer { get; set; } + + [Parameter] + public EventCallback OnOlder { get; set; } + + private string RangeText + { + get + { + var first = Pager.FirstRow; + var last = first + Count - 1; + var total = Total.ToString("N0", System.Globalization.CultureInfo.CurrentCulture) + (TotalIsCapped ? "+" : string.Empty); + return Loc.F(S.MeterDetail_PageRange, + first.ToString("N0", System.Globalization.CultureInfo.CurrentCulture), + last.ToString("N0", System.Globalization.CultureInfo.CurrentCulture), + total); + } + } +} diff --git a/src/App/Components/Pages/MeterPage/RecordPagerBar.razor.css b/src/App/Components/Pages/MeterPage/RecordPagerBar.razor.css new file mode 100644 index 0000000..f6387c8 --- /dev/null +++ b/src/App/Components/Pages/MeterPage/RecordPagerBar.razor.css @@ -0,0 +1,14 @@ +.mv-pager { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: .25rem 1rem; + margin-top: .5rem; +} + +.mv-pager__buttons { + display: flex; + flex-wrap: wrap; + gap: .25rem; +} diff --git a/src/App/Components/Pages/MeterPage/RecordTabBase.cs b/src/App/Components/Pages/MeterPage/RecordTabBase.cs new file mode 100644 index 0000000..11accf4 --- /dev/null +++ b/src/App/Components/Pages/MeterPage/RecordTabBase.cs @@ -0,0 +1,141 @@ +using MeterVault.App.Analysis; +using MeterVault.App.MeterDetails; +using MeterVault.Core.Analysis; +using MeterVault.Infrastructure.Dashboard; +using Microsoft.AspNetCore.Components; + +namespace MeterVault.App.Components.Pages.MeterPage; + +/// One committed page of a record tab: for which meter, over which period, and the rows. +public sealed record RecordView(int MeterId, ResolvedPeriod Period, RecordRange Range, RecordPage Page); + +/// +/// The shared behaviour of the meter page's record tabs — Readings, Normalized data, Events (D-50): the rows of the page +/// period (the page's own period/from/to keys, so a drill-down lands on exactly its bucket), one +/// keyset page of at a time, newest first, loaded through a +/// so a late answer for another meter, range or page is never shown. A new meter, period or +/// (the page's data changed) starts again at the newest rows. +/// +public abstract class RecordTabBase : ComponentBase, IDisposable +{ + private (int MeterId, AnalysisQuery Query, int Version)? _loadedFor; + + /// The meter. + [Parameter, EditorRequired] + public MeterDetailView Detail { get; set; } = null!; + + /// The page's analysis state; its period filters the rows. + [Parameter, EditorRequired] + public AnalysisQuery Query { get; set; } = null!; + + /// Bumped by the page whenever the meter's data changed (a reading saved, an event deleted, …). + [Parameter] + public int Version { get; set; } + + /// A period chosen in the tab's toolbar: the page writes it into its address (replace). + [Parameter] + public EventCallback OnQueryChanged { get; set; } + + /// Raised after the tab changed the meter's data itself (a deletion), so the page refreshes everything. + [Parameter] + public EventCallback OnChanged { get; set; } + + [Inject] + protected AnalysisPeriods Periods { get; set; } = null!; + + [Inject] + protected InstanceClock Clock { get; set; } = null!; + + [Inject] + protected MeterDetailService Details { get; set; } = null!; + + [Inject] + protected ILoggerFactory LoggerFactory { get; set; } = null!; + + /// Where the tab is in its pages. + protected RecordPager Pager { get; } = new(); + + /// The tab's load. + protected LoadState> State { get; } = new(); + + /// The page defaults (the toolbar's Reset). + protected AnalysisDefaults Defaults => MeterAnalysisLoader.DefaultsFor(Detail.Id); + + /// The committed page when it is this meter's. + protected RecordView? Current => State.Value is { } value && value.MeterId == Detail.Id ? value : null; + + private LoadSequencer Loads { get; } = new(); + + protected override async Task OnParametersSetAsync() + { + var key = (Detail.Id, MeterAnalysisLoader.ForMeter(Query, Detail.Id), Version); + if (_loadedFor == key) + { + return; + } + + if (_loadedFor?.MeterId != Detail.Id) + { + State.Clear(); + } + + _loadedFor = key; + Pager.Reset(); + await LoadAsync(); + } + + /// Reads one page of rows. + protected abstract Task> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken); + + /// Loads the page the pager points at. + protected Task LoadAsync() + { + var meterId = Detail.Id; + var query = MeterAnalysisLoader.ForMeter(Query, meterId); + var cursor = Pager.Current; + return Loads.RunAsync(State, async token => + { + var period = await Periods.ResolveAsync(query, Clock.Now, token); + var range = MeterRecordRange.Of(period); + var page = await FetchAsync(meterId, range, cursor, token); + return new RecordView(meterId, period, range, page); + }, LoggerFactory.CreateLogger(GetType())); + } + + protected async Task OlderAsync() + { + if (Current?.Page.Next is { } next) + { + Pager.Older(next); + await LoadAsync(); + } + } + + protected async Task NewerAsync() + { + Pager.Newer(); + await LoadAsync(); + } + + protected async Task NewestAsync() + { + Pager.Reset(); + await LoadAsync(); + } + + /// Shows every record: the all period (no date filter on the record tabs). + protected Task ShowAllAsync() => OnQueryChanged.InvokeAsync(Query.WithPeriod(PeriodPreset.AllHistory)); + + /// True for a record dated after the instant the page read now at (D-04): it is listed, and marked. + protected bool IsAfterNow(DateTimeOffset time) => Current is { } view && MeterRecordRange.IsAfterNow(time, view.Period); + + /// An instant in the instance zone, as the record tables show it. + protected string Local(DateTimeOffset instant) => + TimeZoneInfo.ConvertTime(instant, Periods.Zone).ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture); + + public void Dispose() + { + Loads.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/App/Components/Pages/MeterPage/_Imports.razor b/src/App/Components/Pages/MeterPage/_Imports.razor new file mode 100644 index 0000000..b295f1c --- /dev/null +++ b/src/App/Components/Pages/MeterPage/_Imports.razor @@ -0,0 +1,7 @@ +@* The meter page's parts (brief §7.2) speak the analysis layer's types and the meter page's own helpers. *@ +@using MeterVault.App.MeterDetails +@using MeterVault.Core.Analysis +@using MeterVault.Core.Analysis.Coverage +@using MeterVault.Core.Analysis.Quantities +@using MeterVault.Core.Analysis.Virtual +@using MeterVault.Infrastructure.Analysis diff --git a/src/App/Components/Pages/Meters.razor b/src/App/Components/Pages/Meters.razor index b7f832e..e20d08f 100644 --- a/src/App/Components/Pages/Meters.razor +++ b/src/App/Components/Pages/Meters.razor @@ -1,151 +1,126 @@ @page "/meters" +@using MeterVault.App.Energy +@using MeterVault.App.Components.Shared.MeterLists +@using MeterVault.Core.Analysis +@using MeterVault.Infrastructure.Analysis +@using Microsoft.EntityFrameworkCore @inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory @inject ISnackbar Snackbar @inject IDialogService DialogService @inject NavigationManager Nav -@using Microsoft.EntityFrameworkCore -@using MudBlazor +@inject InstanceClock Clock +@inject AnalysisPeriods Periods +@inject AnalysisReader Reader +@inject MeterVault.Infrastructure.Analysis.VirtualMeterService VirtualMeters +@inject NavState NavState +@inject ILogger Logger +@implements IDisposable -MeterVault — @S.Nav_Meters +@* Every meter (brief §7.3, §3.2): the shared meter list, grouped by energy type with a type filter and a search, each + meter with what it measured in the chosen period — a calculated meter by its formula, a meter without data in words — + and how it counts. Names open the meter's Analysis tab for the same period; the quick entry, edit and delete stay + one click away. Deleting names the calculated meters that depend on the meter first (D-33). *@ -
- @S.Common_Meters - - @S.Meters_AddMeter - -
+ + + + + + + @S.Meters_AddMeter + + + -@if (_meters is null) +@if (_query is not null) { - + } -else -{ - @* The whole row opens the meter — on a phone this table collapses to cards, where a name link - is a small target between edit and delete. Grouped by energy type, because that is how people - look for a meter ("the water one"), not by the order they were created in. *@ - - - - - - @S.Common_Name - @S.Common_Mode - @S.Common_Unit - @S.Meters_Sources - @S.Common_LastSeen - @S.Meters_Active - @S.Common_Actions - - - - @context.Key - - - - - @* The link stays a real link (keyboard, open in new tab) but must not also fire the row's - click, or one tap pushes the same page onto the history twice. *@ - @context.Name - @if (!context.IsActive) - { - @S.MeterDetail_Retired - } - - @context.Mode.Display() - @context.Unit - @context.Sources.Count - - @{ - var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max(); - } - @(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—") - - @(context.IsActive ? S.Meters_Yes : S.Meters_No) - - @* Buttons inside a clickable row must not also open the meter. *@ -
- @if (MeterLinks.QuickEntry(context.Id, context.Mode) is { } entry) - { - - - - } - - -
-
-
- - @if (_meters.Count > 0) - { - @Loc.F(S.Meters_NoSearchMatch, _search) - } - -
- @if (_meters.Count == 0) - { - - @S.Meters_EmptyBefore @S.Nav_Import @S.Meters_EmptyAfter - - } -} + + + + + + @S.Meters_EmptyBefore @S.Nav_Import @S.Meters_EmptyAfter + + + + + @code { - private List? _meters; + private readonly LoadSequencer _loads = new(); + private readonly LoadState _state = new(); + private AnalysisQuery? _query; private MeterEditor? _editor; - private string? _search; - private readonly TableGroupDefinition _byEnergyType = new() + /// What the list shows for one period: the resolved period and a row per meter. + private sealed record MeterListState(ResolvedPeriod Period, IReadOnlyList Rows); + + protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged; + + protected override Task OnParametersSetAsync() => SyncAsync(); + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () => { - Indentation = false, - Expandable = false, - Selector = m => m.EnergyType?.DisplayName ?? "—", - }; - - protected override Task OnInitializedAsync() => LoadAsync(); - - private async Task LoadAsync() - { - await using var db = await DbFactory.CreateDbContextAsync(); - var meters = await db.Meters - .AsNoTracking() - .Include(m => m.EnergyType) - .Include(m => m.Sources) - .ToListAsync(); - - // Grouping follows item order, so sort by the group label first. Retired meters sink to the - // bottom of their group: their history still counts, but nobody reads them any more. - _meters = meters - .OrderBy(m => m.EnergyType?.DisplayName, StringComparer.CurrentCultureIgnoreCase) - .ThenBy(m => !m.IsActive) - .ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase) - .ToList(); - } - - private bool Matches(Meter meter) - { - if (string.IsNullOrWhiteSpace(_search)) + // Only this page's own address: a link away fires this too, just before the page goes. + var path = Nav.ToBaseRelativePath(e.Location); + var end = path.IndexOfAny(['?', '#']); + if (!string.Equals((end >= 0 ? path[..end] : path).TrimEnd('/'), "meters", StringComparison.OrdinalIgnoreCase)) { - return true; + return; } - var term = _search.Trim(); - return Contains(meter.Name) || Contains(meter.SerialNumber) || Contains(meter.Location) - || Contains(meter.EnergyType?.DisplayName) || Contains(meter.Mode.Display()); + await SyncAsync(); + StateHasChanged(); + }); - bool Contains(string? value) => value?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true; + private async Task SyncAsync() + { + var query = AnalysisQuery.Parse(Nav.Uri, AnalysisDefaults.History); + if (query == _query) + { + return; + } + + _query = query; + await LoadAsync(query); } + private Task Retry() => _query is null ? Task.CompletedTask : LoadAsync(_query); + + private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, token => ReadAsync(query, token), Logger); + + /// + /// The meters and their period totals: one portfolio read with every meter's own series, in year buckets — only the + /// totals are shown, and they do not depend on the buckets — without a comparison. + /// + private async Task ReadAsync(AnalysisQuery query, CancellationToken token) + { + var period = await Periods.ResolveAsync(query.WithMetric(null).WithScope(QueryScope.Portfolio), Clock.Now, token); + List meters; + await using (var db = await DbFactory.CreateDbContextAsync(token)) + { + meters = await MeterFacts.LoadAsync(db, null, token); + } + + AnalysisResult? result = null; + if (meters.Count > 0) + { + var request = new AnalysisRequest(AnalysisScope.Portfolio, period) { Bucket = BucketSize.Year, IncludeMeterSeries = true }; + result = await Reader.ReadAsync(request, token); + } + + return new MeterListState(period, MeterListRows.Build(meters, result)); + } + + private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, AnalysisDefaults.History); + /// A new meter goes straight to its own page, where adding readings or a source is the next step. private async Task OnSavedAsync((int MeterId, bool Created) saved) { @@ -155,10 +130,10 @@ else return; } - await LoadAsync(); + await Retry(); } - private async Task DeleteAsync(Meter meter) + private async Task DeleteAsync(MeterFacts meter) { await using var db = await DbFactory.CreateDbContextAsync(); var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id); @@ -167,19 +142,30 @@ else ? Loc.F(S.Meters_DeleteConfirmWithData, meter.Name, readings, consumption) : Loc.F(S.Meters_DeleteConfirm, meter.Name); + // Virtual meters whose formula reads this one break with it (D-33): name them before anything is deleted. + var dependents = await VirtualMeters.GetDependentsAsync(meter.Id); + if (dependents.Count > 0) + { + message += " " + Loc.F(S.Meters_DeleteVirtualDependents, string.Join(", ", dependents.Select(d => d.Name))); + } + if (!await Confirm.DeleteAsync(DialogService, S.Meters_DeleteTitle, message)) { return; } - await using var tx = await db.Database.BeginTransactionAsync(); - // reading/consumption FKs are Restrict — remove them first; events/sources/tank/members cascade. - await db.Consumption.Where(c => c.MeterId == meter.Id).ExecuteDeleteAsync(); - await db.Readings.Where(r => r.MeterId == meter.Id).ExecuteDeleteAsync(); - await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync(); - await tx.CommitAsync(); + // Readings and consumption first (restricted keys), and its meter-scoped prices, which no key ties to it; + // events, sources, tank and members cascade. + await MeterVault.Infrastructure.Persistence.EntityDeletion.DeleteMeterAsync(db, meter.Id); Snackbar.Add(S.Common_Deleted, Severity.Success); - await LoadAsync(); + NavState.NotifyMetersChanged(); + await Retry(); + } + + public void Dispose() + { + Nav.LocationChanged -= OnLocationChanged; + _loads.Dispose(); } } diff --git a/src/App/Components/Pages/Overview/OverviewChanges.razor b/src/App/Components/Pages/Overview/OverviewChanges.razor new file mode 100644 index 0000000..016c179 --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewChanges.razor @@ -0,0 +1,155 @@ +@* What changed (brief §7.1 item 4): the bill ranked by its change, either by cost category (the composition's + slices, D-42) or by what is billed (bill lines, standing charges, manual costs). Each row has the current and the + comparison figure, the absolute change always and the percentage only where it applies (D-08), both over the + coverage both periods share (D-07, marked ¹ when that is less than both whole periods); a row links to its scoped + analysis with the same dates. The rows of either grouping add up to the bill in the total row. From the small + breakpoint up a table; on a phone each row stacks into labelled lines, so the change is never scrolled away. *@ + + +
+

@S.Overview_Changes

+ + @S.Overview_ChangesByCategory + @S.Overview_ChangesByMeter + +
+ + @if (Lines.Count <= 1) + { + @S.Overview_ChangesEmpty + } + else + { + + + @NameHeader + @S.Overview_ColCurrent + @if (_compared) + { + @View.Query.Comparison.Display() + @S.AnalysisTable_Change + @S.Overview_ColPercentShort + } + + + + + @if (line.Href is not null) + { + @line.Name + } + else + { + @line.Name + } + @if (line.Detail is not null) + { + @line.Detail + } + + + @FigureCell(line.Current) + @if (_compared) + { + @FigureCell(line.Previous) + @line.ChangeText + @line.PercentText + } + + + @if (_compared && Lines.Any(l => l.Change.IsPartial)) + { + @S.Overview_MatchedNote + } + } +
+ +@code { + /// The committed Overview. + [Parameter, EditorRequired] + public OverviewView View { get; set; } = null!; + + /// One line of the table: a row of either grouping, or the bill's total. + private sealed record ChangeLine( + string Name, + string? Href, + string? Detail, + CostAmount? Current, + CostAmount? Previous, + CostChange Change, + bool IsTotal, + string Tone, + string ChangeText, + string PercentText); + + /// The footnote marker of a change measured over less than both whole periods (D-07). + private const string Marker = " ¹"; + + private bool _byMeter; + private bool _compared; + private object? _builtFrom; + private List _categories = []; + private List _meters = []; + + private IReadOnlyList Lines => _byMeter ? _meters : _categories; + + private string NameHeader => _byMeter ? S.Overview_ColItem : S.Dashboard_ColCategory; + + protected override void OnParametersSet() + { + if (ReferenceEquals(_builtFrom, View)) + { + return; + } + + _builtFrom = View; + _compared = View.Data.PreviousCost is not null; + _categories = LinesOf(View.Data.CategoryChanges); + _meters = LinesOf(View.Data.LineChanges); + } + + private List LinesOf(IReadOnlyList rows) + { + var lines = rows + .Select(r => Line(OverviewText.NameOf(r, View.Data.MeterNames), OverviewText.HrefOf(r, View.Query), OverviewText.DetailOf(r), r.Current, r.Previous, r.Change, false)) + .ToList(); + lines.Add(Line(S.Overview_BillTotal, null, null, View.Data.Cost.Total, View.Data.PreviousCost?.Total, View.Data.CostChange, true)); + return lines; + } + + private ChangeLine Line(string name, string? href, string? detail, CostAmount? current, CostAmount? previous, CostChange change, bool isTotal) + { + var tone = ChangeDisplay.CssClass(ChangeDisplay.Tone(change.Change, ChangePolarities.ForCost(change.Current, change.Previous))); + var text = Format.ChangeAbsolute(change.Change, v => Format.Money(v, View.Currency)) + (change.IsPartial ? Marker : string.Empty); + var percent = change.Change.IsAvailable ? Format.ChangePercent(change.Change) : Format.Unknown; + return new ChangeLine(name, href, detail, current, previous, change, isTotal, tone, text, percent); + } + + /// + /// A figure's amount with its status underneath when it is not complete ("Partial"), its status in words when it has + /// no amount ("Not priced (no tariff)"), or "—" when the row does not occur. + /// + private RenderFragment FigureCell(CostAmount? amount) + { + if (amount is null) + { + return @@Format.Unknown; + } + + var status = FigureText.Of(amount); + if (!status.IsKnown) + { + return amount.Status == CostStatus.Priced + ? @@Format.Unknown + : @@status.Status; + } + + var text = Format.Money(amount.Cost, View.Currency); + return status.IsComplete + ? @@text + : @@text@OverviewText.CostQualifier(amount, status); + } +} diff --git a/src/App/Components/Pages/Overview/OverviewComposition.razor b/src/App/Components/Pages/Overview/OverviewComposition.razor new file mode 100644 index 0000000..905d77e --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewComposition.razor @@ -0,0 +1,253 @@ +@* The bill's composition (brief §7.1 item 5, D-42): the disjoint cost categories, Uncategorized and the standing + charges no category holds — rows that add up to the bill, manual costs in them exactly once. A donut only when every + slice is ≥ 0 (DonutAllowed); a credit larger than its charges is drawn as signed bars around zero instead. Categories + that overlap another (a view on the bill) are listed apart, as views, and never added up. Setting up categories is + never required: without any, the whole bill is Uncategorized, and a hint says how to split it. *@ + + +
+

@S.Overview_Composition

+
+ + @if (_rows.Count == 0) + { + @S.Overview_CompositionEmpty + } + else + { + @if (_donut.Count > 1) + { + + } + else if (_signed) + { + @S.Overview_SignedBarsNote + } + + + + @S.Dashboard_ColCategory + @if (_signed) + { + @S.Overview_SignedBars + } + @S.AnalysisTable_Cost + @if (!_signed) + { + @S.Overview_Share + } + + + + + + @if (row.Href is not null) + { + @row.Name + } + else + { + @row.Name + } + + + @if (_signed) + { + + @if (!row.IsTotal) + { + + } + + } + + @if (row.Status.IsKnown) + { + + @Format.Money(row.Amount, View.Currency) + @if (!row.Status.IsComplete) + { + @row.Chip + } + + } + else + { + @row.Status.Status + } + + @if (!_signed) + { + @(row.Share is { } share ? Format.Number(share * 100, 1) + " %" : row.IsTotal ? string.Empty : Format.Unknown) + } + + + } + + @if (_views.Count > 0) + { +

@S.Overview_Views

+ @S.Overview_ViewsNote +
+ + + @foreach (var view in _views) + { + + + @view.Name + @if (view.Overlaps is not null) + { +
@view.Overlaps
+ } + + + @(view.Status.IsKnown ? Format.Money(view.Amount, View.Currency) : view.Status.Status) + + + } + +
+
+ } + + @if (View.Data.Setup?.FirstGap is CostSetupGap.NoCategories or CostSetupGap.NoMembers) + { + + @(View.Data.Setup.FirstGap == CostSetupGap.NoCategories ? S.Dashboard_SetupNoCategories : S.Dashboard_SetupNoMembers) + @S.Nav_CostCategories + + } +
+ +@code { + /// The committed Overview. + [Parameter, EditorRequired] + public OverviewView View { get; set; } = null!; + + private sealed record Row(string Name, string? Href, string? Color, double? Amount, FigureStatus Status, double? Share) + { + /// The bill's total, the last line. + public bool IsTotal { get; init; } + + /// What qualifies a known amount: the quantities' state for a priced one, else its price coverage. + public string Chip { get; init; } = Status.Status; + + /// The slice's place among the donut's slices, for its palette colour. + public int? DonutIndex { get; set; } + } + + private sealed record ViewRow(string Name, string Href, double? Amount, FigureStatus Status, string? Overlaps); + + private CategoryComposition? _composition; + private List _rows = []; + private List _views = []; + private IReadOnlyList _donut = []; + private bool _signed; + private double _maxAbs; + private object? _builtFrom; + + protected override void OnParametersSet() + { + // Built once per committed value: the donut re-keys only for a new list. + if (ReferenceEquals(_builtFrom, View)) + { + return; + } + + _builtFrom = View; + _composition = View.Data.Cost.Composition; + _rows = []; + _views = []; + _donut = []; + if (_composition is not { } composition) + { + return; + } + + var data = View.Data; + _signed = !composition.DonutAllowed; + var positive = composition.Slices.Sum(s => s.Total.Cost is { } c && c > 0 ? c : 0); + foreach (var slice in composition.Slices) + { + var status = FigureText.Of(slice.Total); + var category = slice.CategoryId is { } id ? composition.Categories.FirstOrDefault(c => c.CategoryId == id) : null; + + // A slice with nothing in it (a category without members) is not a row; one that could not be priced is, and + // so is a category whose members price nothing (calculated views, generation, A-22): it says so. + if (!status.IsKnown && slice.Total.Status == CostStatus.Priced) + { + if (category?.Cover is not { CoverMeterIds.Count: 0, AnalysisOnlyMeterIds.Count: > 0 }) + { + continue; + } + + status = new FigureStatus(false, false, true, S.Overview_CategoryPricesNothing, S.Overview_CategoryPricesNothingDetail, string.Empty); + } + + var href = slice.CategoryId is { } categoryId ? AnalysisLinks.Analysis(QueryScope.ForCategory(categoryId), AnalysisMetric.Cost, View.Query) : null; + var amount = status.IsKnown ? slice.Total.Cost : null; + double? share = !_signed && amount is { } a && positive > CategoryComposition.DonutTolerance ? a / positive : null; + _rows.Add(new Row(OverviewText.SliceName(slice, composition, data.EnergyTypes, data.MeterNames), href, OverviewDonutSlice.SafeColor(category?.ColorHex), amount, status, share) + { + Chip = OverviewText.CostQualifier(slice.Total, status), + }); + } + + _maxAbs = _rows.Select(r => Math.Abs(r.Amount ?? 0)).DefaultIfEmpty(0).Max(); + if (!_signed) + { + var inDonut = _rows.Where(r => r.Amount is > CategoryComposition.DonutTolerance).ToList(); + for (var i = 0; i < inDonut.Count; i++) + { + inDonut[i].DonutIndex = i; + } + + _donut = [.. inDonut.Select(r => new OverviewDonutSlice(r.Name, r.Amount!.Value, r.Color))]; + } + + if (_rows.Count > 0) + { + var totalStatus = FigureText.Of(composition.Total); + _rows.Add(new Row(S.Overview_BillTotal, null, null, totalStatus.IsKnown ? composition.Total.Cost : null, totalStatus, null) + { + IsTotal = true, + Chip = OverviewText.CostQualifier(composition.Total, totalStatus), + }); + } + + var names = composition.Categories.ToDictionary(c => c.CategoryId, c => c.Name); + foreach (var category in composition.Categories.Where(c => c.IsOverlappingView)) + { + var status = FigureText.Of(category.Total); + var others = category.OverlapsWith.Select(o => names.GetValueOrDefault(o)).OfType().ToList(); + _views.Add(new ViewRow( + category.Name, + AnalysisLinks.Analysis(QueryScope.ForCategory(category.CategoryId), AnalysisMetric.Cost, View.Query), + status.IsKnown ? category.Total.Cost : null, + status, + others.Count > 0 ? Loc.F(S.Overview_ViewOverlaps, string.Join(", ", others)) : category.Cover.LiesOutsideBill ? S.Overview_ViewOutsideBill : null)); + } + } + + /// The swatch of a donut slice: its category's colour, else the palette hue the donut gives it (same order). + private string SwatchStyle(Row row) => + _donut.Count <= 1 || row.DonutIndex is not { } index ? "visibility:hidden" + : "background:" + (row.Color ?? OverviewDonutSlice.PaletteVariable(index)); + + /// The width of a signed bar in its half: the share of the largest amount either way. + private string BarStyle(double? amount, bool negative) + { + if (amount is not { } value || _maxAbs <= 0 || (negative ? value >= 0 : value <= 0)) + { + return "width:0"; + } + + var percent = Math.Abs(value) / _maxAbs * 100; + return string.Create(System.Globalization.CultureInfo.InvariantCulture, $"width:{percent:0.#}%"); + } +} diff --git a/src/App/Components/Pages/Overview/OverviewComposition.razor.css b/src/App/Components/Pages/Overview/OverviewComposition.razor.css new file mode 100644 index 0000000..c4edceb --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewComposition.razor.css @@ -0,0 +1,41 @@ +.mv-ov-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + margin-right: 6px; + vertical-align: middle; +} + +.mv-ov-bars { + display: flex; + align-items: center; + min-width: 120px; +} + +.mv-ov-bars__neg, +.mv-ov-bars__pos { + display: flex; + flex: 1 1 50%; + height: 10px; +} + +.mv-ov-bars__neg { + justify-content: flex-end; + border-right: 1px solid var(--mud-palette-text-secondary); +} + +.mv-ov-bars__neg .mv-ov-bars__bar { + background: var(--mud-palette-info); + border-radius: 2px 0 0 2px; +} + +.mv-ov-bars__pos .mv-ov-bars__bar { + background: var(--mud-palette-primary); + border-radius: 0 2px 2px 0; +} + +.mv-ov-bars__bar { + display: block; + height: 100%; +} diff --git a/src/App/Components/Pages/Overview/OverviewCostCard.razor b/src/App/Components/Pages/Overview/OverviewCostCard.razor new file mode 100644 index 0000000..4f4d6c4 --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewCostCard.razor @@ -0,0 +1,61 @@ +@* The period's cost (brief §7.1 item 1): the bill with its price coverage (priced / partly / not priced), the change + over the coverage both periods share (D-07), what it is made of (metered usage, standing charges, manual costs, the + feed-in credit), a projection only where D-09 allows one, and the way into the cost analysis with the same dates. *@ + + + @if (_parts.Count > 0) + { +
+ @foreach (var (label, amount) in _parts) + { +
+
@label
+
@amount
+
+ } +
+ } + @if (Data.Projection is { } projection) + { + + } +
+ +@code { + /// The Overview's read model. + [Parameter, EditorRequired] + public DashboardOverview Data { get; set; } = null!; + + /// The page's analysis state; the link carries its dates. + [Parameter, EditorRequired] + public AnalysisQuery Query { get; set; } = null!; + + private List<(string Label, string Amount)> _parts = []; + + protected override void OnParametersSet() + { + var total = Data.Cost.Total; + var currency = Data.Cost.Currency; + _parts = []; + + // Only a bill made of more than one part is broken down; the parts add up to the total. + var parts = new List<(string, double?)> + { + (S.Overview_CostUsage, total.Usage), + (S.Overview_CostStanding, total.StandingCharge), + (S.Overview_CostManual, total.Manual), + (S.Overview_CostCredit, total.FeedInCredit is { } credit ? -credit : null), + }; + var known = parts.Where(p => p.Item2 is not null).ToList(); + if (known.Count > 1) + { + _parts = [.. known.Select(p => (p.Item1, Format.Money(p.Item2, currency)))]; + } + } +} diff --git a/src/App/Components/Pages/Overview/OverviewCostCard.razor.css b/src/App/Components/Pages/Overview/OverviewCostCard.razor.css new file mode 100644 index 0000000..f51a0bb --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewCostCard.razor.css @@ -0,0 +1,23 @@ +.mv-ov-parts { + display: flex; + flex-direction: column; + gap: 2px; + margin: 8px 0 0 0; + font-size: 0.8125rem; +} + +.mv-ov-parts__row { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.mv-ov-parts__row dt { + color: var(--mud-palette-text-secondary); +} + +.mv-ov-parts__row dd { + margin: 0; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} diff --git a/src/App/Components/Pages/Overview/OverviewCoverage.razor b/src/App/Components/Pages/Overview/OverviewCoverage.razor new file mode 100644 index 0000000..c6c9f23 --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewCoverage.razor @@ -0,0 +1,93 @@ +@* The Overview's coverage and freshness summary (brief §7.1): how complete the chosen period is, which dates the + instance has data for (meters and manual costs, D-19), the latest month with data and what it rests on, and how + current the meters are (D-18) — one wrapping line under the toolbar, words and icons, never colour alone. *@ + +
+ + + + + @if (Data.Latest is { } latest) + { + + + } + @if (_freshness.Count > 0) + { + + + } +
+ +@code { + /// The Overview's read model. + [Parameter, EditorRequired] + public DashboardOverview Data { get; set; } = null!; + + private List _freshness = []; + private int _stale; + + /// + /// The period as a whole: complete when every measure is, no data when none has any, being prepared while a rebuild + /// runs, partial otherwise. Without any meter, the bill's own availability (manual costs are always available). + /// + private BucketStatus PeriodStatus + { + get + { + if (Data.IsPending) + { + return BucketStatus.Pending; + } + + if (Data.HasNoData) + { + return BucketStatus.Missing; + } + + var statuses = Data.Quantities.Measures.Select(m => m.Total.Status).ToList(); + if (statuses.Count == 0) + { + return Data.Cost.Total.Cost is null ? BucketStatus.Missing : Data.Cost.Total.Availability; + } + + return statuses.All(s => s == BucketStatus.Available) ? BucketStatus.Available + : statuses.All(s => s == BucketStatus.Missing) ? BucketStatus.Missing + : BucketStatus.Partial; + } + } + + protected override void OnParametersSet() + { + var meters = Data.Quantities.Series.Where(s => s.Basis == SeriesBasis.Physical).ToList(); + _stale = meters.Count(s => s.Freshness.State == FreshnessState.Stale); + _freshness = []; + Add(meters.Count(s => s.Freshness.State == FreshnessState.Live), S.Overview_MetersLive); + Add(_stale, S.Overview_MetersStale); + Add(meters.Count(s => s.Freshness.State == FreshnessState.Historical), S.Overview_MetersHistorical); + Add(meters.Count(s => s.Freshness.State == FreshnessState.NoData), S.Overview_MetersNoData); + } + + private void Add(int count, string format) + { + if (count > 0) + { + _freshness.Add(Loc.F(format, count)); + } + } +} diff --git a/src/App/Components/Pages/Overview/OverviewCoverage.razor.css b/src/App/Components/Pages/Overview/OverviewCoverage.razor.css new file mode 100644 index 0000000..48ae1a3 --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewCoverage.razor.css @@ -0,0 +1,17 @@ +.mv-ov-coverage { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 20px; + margin: 8px 0 16px 0; + font-size: 0.875rem; + color: var(--mud-palette-text-secondary); +} + +.mv-ov-coverage__item { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow-wrap: anywhere; +} diff --git a/src/App/Components/Pages/Overview/OverviewDonut.razor b/src/App/Components/Pages/Overview/OverviewDonut.razor new file mode 100644 index 0000000..12c8bd6 --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewDonut.razor @@ -0,0 +1,87 @@ +@* The bill's composition as a donut (D-42) — drawn only for a non-negative, disjoint composition; the panel decides + that and shows the same values in its table. Amounts in the instance currency in the tooltip; transparent, in the + theme's mode, re-keyed on a theme change or a new result; no animation. *@ + +@using ApexCharts +@implements IDisposable +@inject ThemeState ThemeState + +@if (Slices.Count > 0) +{ +
+ + + +
+} + +@code { + /// The slices, each with a positive amount, in the order the table lists them. + [Parameter, EditorRequired] + public IReadOnlyList Slices { get; set; } = []; + + /// The ISO currency of the amounts. + [Parameter, EditorRequired] + public string Currency { get; set; } = "EUR"; + + /// What the chart shows, for its accessible name. + [Parameter, EditorRequired] + public string Title { get; set; } = string.Empty; + + [Parameter] + public int Height { get; set; } = 260; + + private ApexChartOptions _options = new(); + private long _generation; + private object? _builtFrom; + + protected override void OnInitialized() => ThemeState.Changed += OnThemeChanged; + + protected override void OnParametersSet() + { + var source = (Slices, Currency, System.Globalization.CultureInfo.CurrentCulture.Name); + if (!Equals(_builtFrom, source)) + { + _builtFrom = source; + Rebuild(); + } + } + + private void Rebuild() + { + var palette = ChartPalette.For(ThemeState.IsDark); + var mode = palette.IsDark ? Mode.Dark : Mode.Light; + var money = ChartFormatters.Axis(MeterVault.App.Format.CurrencySymbol(Currency), System.Globalization.CultureInfo.CurrentCulture); + _options = new ApexChartOptions + { + Chart = new Chart + { + Background = "transparent", + ForeColor = palette.Text, + Toolbar = new Toolbar { Show = false }, + Animations = new Animations { Enabled = false }, + RedrawOnParentResize = true, + }, + Theme = new ApexCharts.Theme { Mode = mode }, + Colors = [.. Slices.Select((s, i) => s.Color ?? palette.SeriesColor(i))], + Legend = new Legend { Position = LegendPosition.Bottom }, + Stroke = new Stroke { Colors = [palette.Surface], Width = 2 }, + Tooltip = new Tooltip { Enabled = true, Theme = mode, Y = new TooltipY { Formatter = money } }, + PlotOptions = new PlotOptions { Pie = new PlotOptionsPie { Donut = new PlotOptionsDonut { Size = "62%" } } }, + }; + _generation++; + } + + private void OnThemeChanged() => _ = InvokeAsync(() => + { + Rebuild(); + StateHasChanged(); + }); + + public void Dispose() => ThemeState.Changed -= OnThemeChanged; +} diff --git a/src/App/Components/Pages/Overview/OverviewHistory.razor b/src/App/Components/Pages/Overview/OverviewHistory.razor new file mode 100644 index 0000000..0c2fab7 --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewHistory.razor @@ -0,0 +1,65 @@ +@* The Overview's shared history chart (brief §7.1 item 3): the bill, or one measure of one energy type, in the + toolbar's buckets with the comparison drawn over it (A-10 pairs), unknown buckets as gaps. The selection lives in the + address (chart=…, replace) so a reload or a shared link shows the same; a bucket click drills into it on the Overview + (D-51); the table is the chart's accessible alternative. Every option comes pre-built from the one committed value. *@ + + +
+

@S.Overview_History

+
+ + @foreach (var option in View.ChartOptions) + { + @option.Label + } + +
+
+ + + +
+ + @(_table ? S.Overview_HideTable : S.Overview_ShowTable) + + @S.Overview_OpenInAnalysis +
+ + @if (_table) + { + + } +
+ +@code { + /// The committed Overview. + [Parameter, EditorRequired] + public OverviewView View { get; set; } = null!; + + /// The selected option's key (chart=); the bill when unknown. + [Parameter] + public string? ChartKey { get; set; } + + /// Another option was chosen: the page writes it into its address. + [Parameter] + public EventCallback ChartKeyChanged { get; set; } + + /// A bucket was clicked, with the resolution behind the selected option (D-51). + [Parameter] + public EventCallback<(AnalysisBucket Bucket, ResolutionClass? Resolution)> OnDrill { get; set; } + + private bool _table; + + private OverviewChartOption Selected => View.Option(ChartKey); + + private IReadOnlyList? Pairs => + View.Data.Pairs.Count > 0 ? View.Data.Pairs : View.Data.Quantities.Comparison?.Buckets; + + private Task OnSelect(string key) => ChartKeyChanged.InvokeAsync(key); + + private Task OnBucketClick(AnalysisBucket bucket) => OnDrill.InvokeAsync((bucket, Selected.Resolution)); +} diff --git a/src/App/Components/Pages/Overview/OverviewTypeCard.razor b/src/App/Components/Pages/Overview/OverviewTypeCard.razor new file mode 100644 index 0000000..398d3bc --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewTypeCard.razor @@ -0,0 +1,143 @@ +@* One energy type on the Overview (brief §7.1 item 2): each of its measures (D-22) in its own unit — never added across + units — with its status and its change over the matched coverage (D-07); its part of the bill with the billing basis + and change; how current its meters are; and the way into the type's analysis with the same dates. Works without any + tariff or category: a type nothing prices says so instead of showing 0. *@ + + +
+
+

@Figures.Type.Name

+ @if (Figures.Freshness.State != FreshnessState.NoData) + { + + + } +
+ + @if (Figures.Measures.Count == 0) + { + @S.Overview_TypeNoMeasures + } + else if (Figures.HasNoValues) + { + @S.Empty_NoDataForPeriod + } + +
+ @foreach (var measure in Figures.Measures) + { + var status = FigureText.Of(measure.Total); +
+
@(measure.Key.Measure?.Display() ?? measure.Kind.Display())
+
+ + @(status.IsKnown ? Format.Quantity(measure.Total.Value, measure.Unit) : status.Status) + + @if (status.IsKnown && !status.IsComplete) + { + @status.Status + } + @if (status.IsKnown && measure.Comparison is { } comparison) + { + + } +
+
+ } + + @if (Figures.Cost is { } cost) + { + var status = FigureText.Of(cost.Total); +
+
@S.AnalysisTable_Cost
+
+ @if (cost.Basis == BillingBasis.None && cost.Total.Cost is null) + { + @cost.Basis.Display() + } + else + { + + @(status.IsKnown ? Format.Money(cost.Total.Cost, Currency) : status.Status) + + @if (status.IsKnown && !status.IsComplete) + { + @OverviewText.CostQualifier(cost.Total, status) + } + @cost.Basis.Display() + @if (status.IsKnown && Figures.CostChange.Basis != CostChangeBasis.NoComparison) + { + + } + } +
+
+ } +
+ + @if (_matchedOnly) + { +

@S.Overview_MatchedNote

+ } + +
+
+ +@code { + /// The type's figures. + [Parameter, EditorRequired] + public OverviewTypeFigures Figures { get; set; } = null!; + + /// The page's analysis state; the link carries its dates. + [Parameter, EditorRequired] + public AnalysisQuery Query { get; set; } = null!; + + /// The currency of every amount. + [Parameter, EditorRequired] + public string Currency { get; set; } = "EUR"; + + /// The instance zone, for the date of the last reading. + [Parameter, EditorRequired] + public TimeZoneInfo Zone { get; set; } = TimeZoneInfo.Utc; + + /// The footnote marker of a change measured over less than both whole periods (D-07). + private const string Marker = "¹"; + + private bool _matchedOnly; + + protected override void OnParametersSet() => + _matchedOnly = Figures.Measures.Any(m => m.Total.Value is not null && IsMatchedOnly(m)) + || (Figures.Cost?.Total.Cost is not null && Figures.CostChange.IsPartial); + + /// + /// True when a measure's change is stated over the coverage both periods share rather than over both whole periods: + /// either side is not complete (D-07). + /// + private static bool IsMatchedOnly(AnalysisSeries measure) => + measure.Comparison is { Change.IsAvailable: true } comparison + && (measure.Total.Status != BucketStatus.Available || comparison.Total.Status != BucketStatus.Available); + + private string FreshnessIcon => Figures.Freshness.State switch + { + FreshnessState.Live => Icons.Material.Outlined.Sensors, + FreshnessState.Stale => Icons.Material.Outlined.SensorsOff, + _ => Icons.Material.Outlined.History, + }; + + private string? FreshnessDetail => Figures.Freshness.LastActivity is { } last + ? Loc.F(S.Overview_LastActivity, Format.Date(PeriodResolver.LocalDate(last, Zone))) + : null; + +} diff --git a/src/App/Components/Pages/Overview/OverviewTypeCard.razor.css b/src/App/Components/Pages/Overview/OverviewTypeCard.razor.css new file mode 100644 index 0000000..b5f5706 --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewTypeCard.razor.css @@ -0,0 +1,96 @@ +.mv-ov-type { + height: 100%; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.mv-ov-type__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 4px 12px; +} + +.mv-ov-type__name { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +.mv-ov-type__fresh { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: var(--mud-palette-text-secondary); +} + +.mv-ov-type__rows { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + flex: 1 1 auto; +} + +.mv-ov-type__row dt { + font-size: 0.75rem; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--mud-palette-text-secondary); +} + +.mv-ov-type__row dd { + margin: 0; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 8px; +} + +.mv-ov-type__row--cost { + border-top: 1px solid var(--mud-palette-lines-default); + padding-top: 8px; +} + +.mv-ov-type__value { + font-size: 1.25rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.mv-ov-type__value--words { + font-size: 1rem; + font-weight: 400; + color: var(--mud-palette-text-secondary); +} + +.mv-ov-type__status, +.mv-ov-type__basis { + font-size: 0.75rem; + color: var(--mud-palette-text-secondary); +} + +.mv-ov-type__status { + border: 1px solid var(--mud-palette-lines-default); + border-radius: 999px; + padding: 0 8px; +} + +.mv-ov-type__row dd ::deep .mv-change { + flex-basis: 100%; +} + +.mv-ov-type__note { + margin: 0; + font-size: 0.75rem; + color: var(--mud-palette-text-secondary); +} + +.mv-ov-type__link { + align-self: flex-start; +} diff --git a/src/App/Components/Pages/Overview/OverviewView.cs b/src/App/Components/Pages/Overview/OverviewView.cs new file mode 100644 index 0000000..382bf6a --- /dev/null +++ b/src/App/Components/Pages/Overview/OverviewView.cs @@ -0,0 +1,264 @@ +using MeterVault.App.Analysis; +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; +using MeterVault.Infrastructure.Dashboard; +using Microsoft.AspNetCore.WebUtilities; + +namespace MeterVault.App.Components.Pages.Overview; + +/// +/// One thing the Overview's history chart can show (brief §7.1 item 3): the bill, or one measure of one energy type — +/// with its chart and table series built once, in the load's culture, so the chart re-keys only for a new result. +/// +/// The invariant token in the address (chart=): or a measure's . +/// What the selector says ("Strom · Total use (kWh)"); a type's name is user data. +/// The metric it charts, for the Analysis link and the export. +/// The scope it belongs to: the portfolio for the bill, the energy type for a measure. +/// The chart series: the value and, with a comparison, its overlay. +/// The table series (the chart's accessible alternative). +/// The coarsest resolution behind it, for drilling into a bucket (D-51). +public sealed record OverviewChartOption( + string Key, + string Label, + AnalysisMetric Metric, + QueryScope Scope, + IReadOnlyList Chart, + IReadOnlyList Table, + ResolutionClass? Resolution); + +/// +/// The Overview as one committed value (the LoadSequencer pattern): the query and period it answers, the read model, and +/// everything built from it in the reader's culture. Every panel renders from this one value, so a title never sits above +/// another period's chart. +/// +/// The analysis state the value answers. +/// The read model. +/// What the history chart can show; the bill first. +/// The meter and energy type names attention items speak of. +/// How many attention items there are (the layout makes room for them only then). +public sealed record OverviewView( + AnalysisQuery Query, + DashboardOverview Data, + IReadOnlyList ChartOptions, + AttentionNames Names, + int AttentionCount) +{ + /// The address key of the history chart's selection. + public const string ChartParameter = "chart"; + + /// The token of the bill in ; the default, never written. + public const string CostKey = "cost"; + + public ResolvedPeriod Period => Data.Period; + + /// The instance currency every amount is in (D-43). + public string Currency => Data.Cost.Currency; + + /// The problems of the quantity and the cost reader together (duplicates collapse in the list). + public IEnumerable Problems => Data.Quantities.Problems.Concat(Data.Cost.QuantityProblems); + + /// The option names, or the bill. + public OverviewChartOption Option(string? key) => + ChartOptions.FirstOrDefault(o => string.Equals(o.Key, key, StringComparison.Ordinal)) ?? ChartOptions[0]; + + /// Builds the view of for in the current culture. + public static OverviewView Build(AnalysisQuery query, DashboardOverview data) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(data); + + var typeNames = data.EnergyTypes.ToDictionary(t => t.Id, t => t.Name); + var categoryNames = (data.Cost.Composition?.Categories ?? []).ToDictionary(c => c.CategoryId, c => c.Name); + var names = new AttentionNames(data.MeterNames, typeNames, categoryNames); + var attention = AttentionItems.Build(data.Quantities.Problems.Concat(data.Cost.QuantityProblems), data.Cost.Attention, names, query); + return new OverviewView(query, data, ChartOptionsOf(query, data, typeNames), names, attention.Count); + } + + /// The chart key of an address; null when absent. + public static string? ChartKeyOf(string uri) + { + var start = uri.IndexOf('?', StringComparison.Ordinal); + if (start < 0) + { + return null; + } + + var end = uri.IndexOf('#', start); + var query = QueryHelpers.ParseQuery(end < 0 ? uri[start..] : uri[start..end]); + return query.TryGetValue(ChartParameter, out var values) && values.Count > 0 && !string.IsNullOrWhiteSpace(values[0]) ? values[0]!.Trim() : null; + } + + /// "Strom · Total use (kWh)": a measure as the chart selector names it. + public static string MeasureLabel(string typeName, AnalysisSeries measure) + { + ArgumentNullException.ThrowIfNull(measure); + + var what = measure.Key.Measure is { } m ? m.Display() : measure.Kind.Display(); + return Loc.F(Strings.Overview_ChartMeasure, typeName, what, measure.Unit); + } + + private static List ChartOptionsOf(AnalysisQuery query, DashboardOverview data, Dictionary typeNames) + { + var buckets = data.Plan.Buckets; + var currency = data.Cost.Currency; + var costName = Strings.Overview_TotalCost; + + List costChart = [AnalysisChartSeries.ForCost(CostKey, costName, currency, data.Cost.Buckets)]; + var costTable = AnalysisTableSeries.ForCosts(CostKey, costName, currency, data.Cost.Buckets, data.Cost.Total); + if (data.PreviousCost is { } previous && previous.Buckets.Count == buckets.Count) + { + costChart.Add(AnalysisChartSeries.ComparisonForCost( + CostKey, AnalysisChartSeries.ComparisonName(costName, query.Comparison), currency, previous.Buckets)); + costTable = costTable.WithComparisonCosts(previous.Buckets, previous.Total, currency) with + { + TotalChange = data.CostChange.Change.IsAvailable ? data.CostChange.Change : null, + }; + } + + string? MeterNameOf(int id) => data.MeterNames.GetValueOrDefault(id); + + var options = new List + { + new(CostKey, costName, AnalysisMetric.Cost, QueryScope.Portfolio, costChart, [costTable], data.CoarsestResolution), + }; + + foreach (var type in data.Types) + { + var typeName = typeNames.GetValueOrDefault(type.Type.Id, type.Type.Name); + foreach (var measure in type.Measures) + { + var label = MeasureLabel(typeName, measure); + List chart = [AnalysisChartSeries.ForSeries(measure, label, meterName: MeterNameOf)]; + if (AnalysisChartSeries.ComparisonOf(measure, AnalysisChartSeries.ComparisonName(label, query.Comparison), meterName: MeterNameOf) is { } overlay) + { + chart.Add(overlay); + } + + options.Add(new OverviewChartOption( + measure.Key.Id, + label, + AnalysisMetrics.MetricOf(measure.Kind) ?? AnalysisMetric.Consumption, + QueryScope.ForEnergyType(type.Type.Id), + chart, + [AnalysisTableSeries.ForSeries(measure, label, MeterNameOf)], + data.ResolutionOf(measure))); + } + } + + return options; + } +} + +/// One slice of the composition donut: its name (user data or the page's words), its positive amount and colour. +/// The slice's name. +/// Its cost, > 0. +/// Its category's colour (#rrggbb); null for the palette's hue by position. +public sealed record OverviewDonutSlice(string Label, double Amount, string? Color) +{ + private static readonly string[] PaletteNames = ["primary", "secondary", "info", "error", "warning", "success"]; + + /// A category colour when it is a plain hex colour; null otherwise (it goes into a style and a script). + public static string? SafeColor(string? color) => + color is { Length: 4 or 7 } hex && hex[0] == '#' && hex.Skip(1).All(Uri.IsHexDigit) ? hex : null; + + /// The theme variable of the palette hue the chart gives the -th slice (ChartPalette's order). + public static string PaletteVariable(int index) => "var(--mud-palette-" + PaletteNames[((index % PaletteNames.Length) + PaletteNames.Length) % PaletteNames.Length] + ")"; +} + +/// The words and links of the Overview's rows: a composition slice, a bill line, a standing charge, the manual costs. +public static class OverviewText +{ + /// A row's name in the reader's language; categories, meters and types keep their own names (user data). + public static string NameOf(OverviewChangeRow row, IReadOnlyDictionary? meterNames = null) + { + ArgumentNullException.ThrowIfNull(row); + + return row.Kind switch + { + OverviewRowKind.Uncategorized => Strings.Dashboard_SliceUncategorized, + OverviewRowKind.StandingCharge => StandingChargeName(row.StandingCharge, row.Name), + OverviewRowKind.ManualCosts => Strings.Overview_CostManual, + OverviewRowKind.Line when row.ForMeterId is { } forMeter => + Loc.F(Strings.Overview_LineFor, row.Name, meterNames?.GetValueOrDefault(forMeter) ?? Loc.F(Strings.Attention_MeterFallback, forMeter)), + _ => row.Name, + }; + } + + /// "Standing charge — Strom", "Standing charge — global". + public static string StandingChargeName(StandingChargeKey? key, string name) => + key is { Scope: TariffScope.Global } || string.IsNullOrWhiteSpace(name) + ? Strings.Dashboard_SliceStandingChargeGlobal + : Loc.F(Strings.Dashboard_SliceStandingCharge, name); + + /// What a line row is when it is not a plain unit-price line ("Feed-in credit", "Own meter price"); null otherwise. + public static string? DetailOf(OverviewChangeRow row) + { + ArgumentNullException.ThrowIfNull(row); + + return row is { Kind: OverviewRowKind.Line, LineKind: { } kind } && kind != BillLineKind.UnitPrice ? kind.Display() : null; + } + + /// + /// Where a row is explored, with the same dates (brief §7.1 item 4): a category's cost analysis, a meter's page, an + /// energy type's page, the portfolio's cost analysis for the global charge and the manual costs; none for + /// Uncategorized, which is no scope. + /// + public static string? HrefOf(OverviewChangeRow row, AnalysisQuery query) + { + ArgumentNullException.ThrowIfNull(row); + ArgumentNullException.ThrowIfNull(query); + + return row.Kind switch + { + OverviewRowKind.Category when row.CategoryId is { } category => + AnalysisLinks.Analysis(QueryScope.ForCategory(category), AnalysisMetric.Cost, query), + OverviewRowKind.Line when row.MeterId is { } meter => MeterLinks.Analysis(meter, query), + OverviewRowKind.StandingCharge when row.MeterId is { } meter => MeterLinks.Analysis(meter, query), + OverviewRowKind.StandingCharge when row.EnergyTypeId is { } type => AnalysisLinks.EnergyType(type, null, query), + OverviewRowKind.StandingCharge or OverviewRowKind.ManualCosts => + AnalysisLinks.Analysis(QueryScope.Portfolio, AnalysisMetric.Cost, query), + _ => null, + }; + } + + /// A composition slice's name: the category's, Uncategorized, or its standing charge's. + public static string SliceName(CompositionSlice slice, CategoryComposition composition, IReadOnlyList types, IReadOnlyDictionary meterNames) + { + ArgumentNullException.ThrowIfNull(slice); + ArgumentNullException.ThrowIfNull(composition); + ArgumentNullException.ThrowIfNull(types); + ArgumentNullException.ThrowIfNull(meterNames); + + return slice.Kind switch + { + CompositionSliceKind.Category => composition.Categories.FirstOrDefault(c => c.CategoryId == slice.CategoryId)?.Name ?? string.Empty, + CompositionSliceKind.Uncategorized => Strings.Dashboard_SliceUncategorized, + _ => StandingChargeName(slice.StandingCharge, slice.StandingCharge switch + { + { Scope: TariffScope.EnergyType, ScopeId: { } type } => types.FirstOrDefault(t => t.Id == type)?.Name ?? string.Empty, + { Scope: TariffScope.Meter, ScopeId: { } meter } => meterNames.GetValueOrDefault(meter, string.Empty), + _ => string.Empty, + }), + }; + } + + /// + /// What qualifies a known cost that is not complete: for a fully priced figure over incomplete quantities, the + /// quantities' state ("Partial"); otherwise its price coverage ("Partly priced") — as says it. + /// + public static string CostQualifier(CostAmount amount, FigureStatus status) + { + ArgumentNullException.ThrowIfNull(amount); + ArgumentNullException.ThrowIfNull(status); + + return amount.Status == CostStatus.Priced && amount.Availability != BucketStatus.Available ? amount.Availability.Display() : status.Status; + } + + /// The caption of a change: what it is compared with, and — when only part of the period could be matched — that it is. + public static string? ChangeCaption(AnalysisQuery query, CostChange change) => CostChanges.Caption(query, change); +} diff --git a/src/App/Components/Pages/Overview/_Imports.razor b/src/App/Components/Pages/Overview/_Imports.razor new file mode 100644 index 0000000..6ad2836 --- /dev/null +++ b/src/App/Components/Pages/Overview/_Imports.razor @@ -0,0 +1,6 @@ +@* The Overview's panels speak the analysis layer's types directly (periods, buckets, values, cost figures). *@ +@using MeterVault.App.Theme +@using MeterVault.Core.Analysis +@using MeterVault.Core.Analysis.Costing +@using MeterVault.Core.Analysis.Totals +@using MeterVault.Infrastructure.Analysis diff --git a/src/App/Components/Pages/Solar.razor b/src/App/Components/Pages/Solar.razor index 15d0ac5..19713ef 100644 --- a/src/App/Components/Pages/Solar.razor +++ b/src/App/Components/Pages/Solar.razor @@ -1,149 +1,100 @@ @page "/solar" +@using MeterVault.App.Components.Pages.Specialized +@using MeterVault.Core.Analysis +@implements IDisposable +@inject NavigationManager Nav +@inject InstanceClock Clock @inject SolarService SolarSvc -@using MudBlazor +@inject ILogger Logger -MeterVault — @S.Nav_Solar +@* The Solar view (brief §7.5, D-54): the shared header, toolbar and missing-data semantics around the specialised + measures — generation, self-consumption, feed-in, autarky and what the generation is worth — one section per energy + type with generation. Each figure carries its status and says how it was obtained; a role the figures need and + nobody holds gets a setup card with a scoped path into the meter editor. *@ -
- @S.Nav_Solar - - @S.Common_RangeLast12Months - @S.Common_RangeLast24Months - @S.Common_RangeLast5Years - @S.Common_RangeAllTime - -
+ + + + + -@if (_summary is null) +@if (_query is not null) { - + } -else if (!_summary.HasGeneration) -{ - - @S.Solar_NoGenerationLead @MeterMode.GenerationCounter.Display() @S.Solar_NoGenerationTail - @S.Nav_Meters@S.Solar_NoGenerationOrImport - @S.Nav_Import. - -} -else -{ - - - - @S.Solar_Generation - @Format.Number(_summary.Generation, 0) kWh - - - - - @S.Solar_SelfConsumption - @(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—") - @if (_summary.SelfConsumptionRatio is { } ratio) - { - @Loc.F(S.Solar_ShareOfGeneration, Format.Number(ratio * 100, 0)) - } - - - - - @S.Solar_Autarky - @(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—") - @if (_summary.GridImport is { } grid) - { - @Loc.F(S.Solar_GridDraw, Format.Number(grid, 0)) - } - - - - - @S.Solar_Savings - @(_summary.Savings is { } sav ? Format.Euro(sav) : "—") - - - - - @S.Solar_GenerationAndSelfConsumption - - - - - - - @S.Solar_GenerationByMeter - - - @foreach (var meter in _summary.Meters) - { - - @meter.Name - @Format.Number(meter.Generation, 0) kWh - - } - - - @if (!_summary.HasLoadContext) - { - - @S.Solar_TagMetersLead total_load @S.Solar_TagMetersMid grid_import@S.Solar_TagMetersTail - @S.Nav_Meters. - - } - - - -} + + @if (view.Analysis.Sites.Count == 0) + { +
+
+ } + else if (!view.Analysis.IsRefused) + { + @foreach (var site in view.Sites) + { + + } + } +
@code { - private int _months = 60; - private bool _loading; - private SolarSummary? _summary; - private IReadOnlyList _chart = []; + private static readonly AnalysisDefaults Defaults = AnalysisDefaults.History; - protected override Task OnInitializedAsync() => LoadAsync(); + private readonly LoadSequencer _loads = new(); + private readonly LoadState _state = new(); + private AnalysisQuery? _query; - private async Task OnRangeChanged(int months) + /// One committed result: the query it answers, the read model and every section's series. + private sealed record SolarPageView(AnalysisQuery Query, SolarAnalysis Analysis, IReadOnlyList Sites); + + protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged; + + protected override Task OnParametersSetAsync() => ReloadIfChangedAsync(); + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(ReloadIfChangedAsync); + + private async Task ReloadIfChangedAsync() { - _months = months; - await LoadAsync(); - } - - private async Task LoadAsync() - { - if (_loading) + var query = AnalysisQuery.Parse(Nav.Uri, Defaults); + if (query == _query) { return; } - _loading = true; - _summary = null; - try - { - var asOf = DateOnly.FromDateTime(DateTime.UtcNow); - var from = asOf.AddMonths(-_months); - _summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1)); + _query = query; + await LoadAsync(query); + StateHasChanged(); + } - var generation = _summary.Months - .Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Generation)) - .ToList(); - var series = new List - { - new(S.Solar_Generation, ApexCharts.SeriesType.Bar, generation), - }; - if (_summary.HasLoadContext) - { - var self = _summary.Months - .Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.SelfConsumption ?? 0)) - .ToList(); - series.Add(new(S.Solar_SelfConsumption, ApexCharts.SeriesType.Bar, self)); - } + private Task RetryAsync() => _query is null ? Task.CompletedTask : LoadAsync(_query); - _chart = series; - } - finally - { - _loading = false; - } + private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token => + { + // "Now" is read once per load (D-01); "all" spans what the energy types with generation have data for (D-19). + var now = Clock.Now; + var availability = query.Period == PeriodPreset.AllHistory ? await SolarSvc.GetAvailabilityAsync(now, token) : null; + var period = query.Resolve(now, SolarSvc.Zone, availability); + var analysis = await SolarSvc.GetAsync(new SolarRequest(period) { Bucket = query.Bucket, Comparison = query.Comparison }, token); + return new SolarPageView(query, analysis, [.. analysis.Sites.Select(s => SolarSiteView.Build(s, query, analysis.Currency))]); + }, Logger); + + private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults); + + public void Dispose() + { + Nav.LocationChanged -= OnLocationChanged; + _loads.Dispose(); } } diff --git a/src/App/Components/Pages/Specialized/SolarSiteSection.razor b/src/App/Components/Pages/Specialized/SolarSiteSection.razor new file mode 100644 index 0000000..69a95a0 --- /dev/null +++ b/src/App/Components/Pages/Specialized/SolarSiteSection.razor @@ -0,0 +1,363 @@ +@using MeterVault.Core.Analysis +@using MeterVault.Core.Analysis.Quantities +@using MeterVault.Infrastructure.Analysis +@inject NavigationManager Nav + +@* One energy type's Solar section (brief §7.5, D-54): its attention items, the period figures as cards — each with its + status, its unit (D-20, never assumed) and how it was obtained — the shared chart and table, the roles the figures + rest on with a setup card for each missing one, and the generation meters with what each counts for. *@ + +
+ @if (ShowHeading) + { +

@Site.EnergyTypeName

+ } + else + { +

@Site.EnergyTypeName

+ } + + + + @if (Site.IsPending) + { + + } + else if (IsEmpty) + { + + @if (Site.Availability is null) + { + @S.Solar_NoDataNextStep + } + + } + else + { + + + + + + @if (Site.SelfConsumption is { } self) + { + + @if (Site.SelfConsumptionShare is { Value: { } share }) + { + @Loc.F(S.Solar_ShareOfGeneration, Format.Quantity(share, "%")) + } + + } + else + { + + } + + + @if (Site.FeedIn is { } feedIn) + { + + } + else + { + + } + + + @if (Site.Autarky is { } autarky) + { + + @if (UseLine is { } line) + { + @line + } + + } + else + { + + } + + + @if (Site.Savings is { } savings) + { + + @if (Site.FeedInCredit is { } credit) + { + @Loc.F(S.Solar_FeedInCreditLine, CreditText(credit.Total)) + } + + } + else + { + + @if (Site.FeedInCredit is { } credit) + { + @Loc.F(S.Solar_FeedInCreditLine, CreditText(credit.Total)) + } + + } + + + + + + + @S.Solar_GenerationAndSelfConsumption + + + + + @S.Solar_TableTitle + + + } + + @if (Site.Roles.Any(r => r.IsSet)) + { +
+ @S.Solar_RolesInUse + @foreach (var role in Site.Roles.Where(r => r.IsSet)) + { + + @role.Role.Display(): + @for (var i = 0; i < role.Holders.Count; i++) + { + var holder = role.Holders[i]; + @if (i > 0) + { + , + } + @holder.Name + } + + } +
+ } + + @if (Site.Roles.Any(r => !r.IsSet)) + { + @S.Solar_SetupTitle + + @foreach (var role in Site.Roles.Where(r => !r.IsSet)) + { + + +
+
+ @role.Role.Meaning() + @RoleUse(role.Role) + @if (role.Candidates.Count > 0) + { + @S.Solar_RoleChooseMeter +
+ @foreach (var candidate in role.Candidates.Take(MaxCandidates)) + { + @candidate.Name + } +
+ } + + @Loc.F(S.Solar_RoleAllMeters, Site.EnergyTypeName) + +
+
+ } +
+ } + + @if (Site.Meters.Count > 0) + { + + @S.Solar_GenerationByMeter +
+ + + + @S.Common_Meter + @S.Solar_Generation + @S.Solar_MeterCounts + + + + @foreach (var meter in Site.Meters) + { + + @meter.Name + + @Format.Quantity(meter.Total.Value, meter.Unit) +
@FigureText.Of(meter.Total).Summary
+ + @(meter.IsCounted ? S.Solar_MeterCounted : meter.IsVirtual ? S.Solar_MeterView : S.Solar_MeterNotCounted) + + } + +
+
+
+ } +
+ +@code { + private const int MaxCandidates = 4; + + /// The section's figures and series, built inside the page's load. + [Parameter, EditorRequired] + public SolarSiteView View { get; set; } = null!; + + /// The page's analysis state; links carry it. + [Parameter, EditorRequired] + public AnalysisQuery Query { get; set; } = null!; + + /// The instance currency (D-43). + [Parameter, EditorRequired] + public string Currency { get; set; } = "EUR"; + + /// Shows the energy type's name as a heading (when there are several sections). + [Parameter] + public bool ShowHeading { get; set; } + + /// Loads again (for "being prepared"). + [Parameter] + public EventCallback OnRefresh { get; set; } + + private readonly string _headingId = "solar-" + Guid.NewGuid().ToString("N")[..8]; + + private SolarSite Site => View.Site; + + /// Nothing in the period: no generation and no self-consumption to show (D-54: never a row of zeros). + private bool IsEmpty => + Site.Quantities.NotYetOccurred + || ((Site.Generation is null || Site.Generation.Total.Status == BucketStatus.Missing) + && (Site.SelfConsumption?.Total.Status is null or BucketStatus.Missing) + && (Site.FeedIn?.Total.Status is null or BucketStatus.Missing)); + + private string? LatestHref => + AnalysisNavigation.LatestData(Query, Site.Availability) is { } latest ? AnalysisLinks.Solar(latest) : null; + + private string? ChangeCaption => Query.Comparison.Kind == ComparisonKind.None ? null : Query.Comparison.Display(); + + private string ChartTitle => Site.Unit is { Length: > 0 } unit + ? S.Solar_GenerationAndSelfConsumption + " (" + unit + ")" + : S.Solar_GenerationAndSelfConsumption; + + /// The change chip only when a comparison was asked for. + private Change? ChangeOf(Change? change) => Query.Comparison.Kind == ComparisonKind.None ? null : change ?? Change.Unavailable; + + private string? GenerationCaption + { + get + { + if (Site.Generation is null) + { + return S.Solar_GenerationNotCounted; + } + + var others = Site.OtherGeneration.Where(o => o.Total.Value is not null).Select(o => Format.Quantity(o.Total.Value, o.Unit)).ToList(); + return others.Count > 0 ? Loc.F(S.Solar_GenerationOtherUnits, string.Join(", ", others)) : null; + } + } + + /// How a figure was obtained, in the role names the editor uses (never raw tokens). + private static string BasisText(SolarFigure figure) + { + if (figure.UnitsDiffer) + { + return Loc.F(S.Solar_BasisUnitsDiffer, string.Join(", ", figure.InputUnits)); + } + + return figure.Basis switch + { + SolarBasis.LoadMinusImport => Loc.F(S.Solar_BasisDifference, MeterRole.TotalLoad.Display(), MeterRole.GridImport.Display()), + SolarBasis.GenerationMinusExport => Loc.F(S.Solar_BasisDifference, S.Solar_Generation, MeterRole.GridExport.Display()), + SolarBasis.GenerationMinusSelfConsumption => Loc.F(S.Solar_BasisDifference, S.Solar_Generation, S.Solar_SelfConsumption), + SolarBasis.SelfConsumptionPlusImport => Loc.F(S.Solar_BasisSum, S.Solar_SelfConsumption, MeterRole.GridImport.Display()), + _ => figure.MeterIds.Count > 0 ? S.Solar_BasisMeasured : string.Empty, + }; + } + + /// "Use 1,234 kWh · bought 567 kWh": what autarky is taken of. + private string? UseLine + { + get + { + var parts = new List(2); + if (Site.SiteUse is { Total.Value: { } use } siteUse) + { + parts.Add(Loc.F(S.Solar_UseValue, Format.Quantity(use, siteUse.Unit))); + } + + if (Site.GridImport is { Total.Value: { } bought } import) + { + parts.Add(Loc.F(S.Solar_ImportValue, Format.Quantity(bought, import.Unit))); + } + + return parts.Count > 0 ? string.Join(" · ", parts) : null; + } + } + + private string SavingsCaption(SolarMoney savings) => + Loc.F(S.Solar_SavingsCaption, View.Names.Meter(savings.MeterIds.FirstOrDefault())); + + /// The credit as a positive amount, or its status in words (never a fabricated 0, D-38). + private string CreditText(Core.Analysis.Costing.CostAmount credit) => + credit.FeedInCredit is { } amount ? Format.Money(amount, Currency) : FigureText.Of(credit).Status; + + /// What holding a role adds to the section. + private static string RoleUse(MeterRole role) => role switch + { + MeterRole.TotalLoad => S.Solar_RoleUseTotalLoad, + MeterRole.GridImport => S.Solar_RoleUseGridImport, + _ => S.Solar_RoleUseGridExport, + }; + + private EventCallback _onBucketClick; + private Func? _drillHref; + + protected override void OnParametersSet() + { + // Buckets are clickable, and the table has its drill column, only when some bucket leads somewhere (D-51): monthly + // data has no days to open. + var drills = Site.Quantities.Plan.Buckets.Any(b => DrillHref(b) is not null); + _onBucketClick = drills ? EventCallback.Factory.Create(this, Drill) : default; + _drillHref = drills ? DrillHref : null; + } + + /// + /// The coarsest resolution among everything the section charts (D-51): the generation and the meters behind the + /// self-consumption, feed-in, use and import figures — a bucket is opened no finer than all of them resolve. + /// + private ResolutionClass? Coarsest => + new[] { Site.SelfConsumption, Site.FeedIn, Site.SiteUse, Site.GridImport } + .OfType() + .SelectMany(f => f.MeterIds) + .Select(id => Site.Quantities.SeriesFor(id)?.Resolution) + .Append(Site.Generation?.Resolution) + .Where(r => r is not null) + .Max(); + + private string? DrillHref(AnalysisBucket bucket) => + AnalysisNavigation.DrillInto(Query, bucket, Coarsest) is { } next ? AnalysisLinks.Solar(next) : null; + + /// The buckets are finer than the data: open the interval that shows it (replacing the address, D-46). + private void UseBucket(BucketSize size) => Nav.NavigateTo(AnalysisLinks.Solar(Query.WithBucket(size)), replace: true); + + /// A chart bucket opens the next finer period (D-51), as a new history entry so Back returns. + private void Drill(AnalysisBucket bucket) + { + if (DrillHref(bucket) is { } href) + { + Nav.NavigateTo(href); + } + } +} diff --git a/src/App/Components/Pages/Specialized/SpecializedViews.cs b/src/App/Components/Pages/Specialized/SpecializedViews.cs new file mode 100644 index 0000000..757774d --- /dev/null +++ b/src/App/Components/Pages/Specialized/SpecializedViews.cs @@ -0,0 +1,131 @@ +using MeterVault.App.Analysis; +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Dashboard; + +namespace MeterVault.App.Components.Pages.Specialized; + +/// +/// One Solar section as the page draws it: the read model and its chart and table series, built once inside the load so +/// they are formatted in the request's culture and the chart re-keys only on a new result (foundation §13.10). +/// +/// The energy type's solar figures. +/// Generation and self-consumption per bucket (one unit), with the generation comparison overlay. +/// Generation, self-consumption (with its savings) and feed-in per bucket. +/// The meter names the attention items and value details speak of. +public sealed record SolarSiteView( + SolarSite Site, IReadOnlyList Chart, IReadOnlyList Table, AttentionNames Names) +{ + /// Builds the series of a section for 's comparison. + public static SolarSiteView Build(SolarSite site, AnalysisQuery query, string currency) + { + ArgumentNullException.ThrowIfNull(site); + ArgumentNullException.ThrowIfNull(query); + + var chart = new List(); + var table = new List(); + var generation = site.Generation; + if (generation is not null) + { + chart.Add(AnalysisChartSeries.ForSeries(generation, Strings.Solar_Generation)); + table.Add(AnalysisTableSeries.ForSeries(generation, Strings.Solar_Generation)); + } + + if (site.SelfConsumption is { UnitsDiffer: false } self) + { + if (generation is null || Units.AreSame(self.Unit, generation.Unit)) + { + chart.Add(new AnalysisChartSeries("self-consumption", Strings.Solar_SelfConsumption, self.Unit, self.Values)); + } + + var row = AnalysisTableSeries.ForValues("self-consumption", Strings.Solar_SelfConsumption, self.Unit, self.Values, self.Total) with + { + Polarity = ChangePolarity.HigherIsBetter, + }; + table.Add(row); + } + + // What the self-consumption saved, as its own money column: it is a value, not a cost of anything. + if (site.Savings is { } savings) + { + table.Add(AnalysisTableSeries.ForCosts("savings", Strings.Solar_Savings, currency, savings.Buckets, savings.Total) with + { + Polarity = ChangePolarity.HigherIsBetter, + }); + } + + if (site.FeedIn is { UnitsDiffer: false } feedIn) + { + table.Add(AnalysisTableSeries.ForValues("feed-in", Strings.Solar_FeedIn, feedIn.Unit, feedIn.Values, feedIn.Total) with + { + Polarity = ChangePolarity.Neutral, + }); + } + + if (generation is not null + && AnalysisChartSeries.ComparisonOf(generation, AnalysisChartSeries.ComparisonName(Strings.Solar_Generation, query.Comparison)) is { } overlay) + { + chart.Add(overlay); + } + + return new SolarSiteView(site, chart, table, AttentionNames.From(site.Quantities)); + } +} + +/// +/// One tank as the page draws it: the read model and its usage chart and table series (with its cost and the burner +/// runtime), built inside the load. +/// +/// The tank's figures. +/// Usage per bucket, with its comparison overlay. +/// Usage per bucket with its cost, and the burner runtime. +/// The meter names the attention items and value details speak of. +public sealed record TankView(TankAnalysis Tank, IReadOnlyList Chart, IReadOnlyList Table, AttentionNames Names) +{ + /// The buckets of the read every tank shares. + public IReadOnlyList Buckets { get; init; } = []; + + /// The comparison buckets paired with (A-10), when a comparison was read. + public IReadOnlyList? Pairs { get; init; } + + /// Builds the series of a tank for 's comparison. + public static TankView Build(TankAnalysis tank, AnalysisResult? quantities, AnalysisQuery query, string currency) + { + ArgumentNullException.ThrowIfNull(tank); + ArgumentNullException.ThrowIfNull(query); + + var chart = new List(); + var table = new List(); + if (tank.Usage is { } usage) + { + chart.Add(AnalysisChartSeries.ForSeries(usage, Strings.Consumables_Used)); + if (AnalysisChartSeries.ComparisonOf(usage, AnalysisChartSeries.ComparisonName(Strings.Consumables_Used, query.Comparison)) is { } overlay) + { + chart.Add(overlay); + } + + var row = AnalysisTableSeries.ForSeries(usage, Strings.Consumables_Used); + // A tank without any tariff says so once (its cost card, its attention item), not in every row. + table.Add(tank.Cost is { Refusal: Infrastructure.Costing.CostRefusal.None } cost && cost.Total.Status != Core.Analysis.Costing.CostStatus.NotPriced + ? row.WithCosts(cost.Buckets, cost.Total, currency) + : row); + } + + foreach (var runtime in tank.Runtime) + { + table.Add(AnalysisTableSeries.ForValues(runtime.Key.Id, Loc.F(Strings.Consumables_RuntimeOf, runtime.Name), runtime.Unit, runtime.Values, runtime.Total) with + { + Polarity = ChangePolarity.Neutral, + IsAdditive = runtime.IsAdditive, + }); + } + + return new TankView(tank, chart, table, AttentionNames.From(quantities, tank.Cost)) + { + Buckets = quantities?.Plan.Buckets ?? [], + Pairs = quantities?.Comparison?.Buckets, + }; + } +} diff --git a/src/App/Components/Pages/Specialized/TankSection.razor b/src/App/Components/Pages/Specialized/TankSection.razor new file mode 100644 index 0000000..daa0a80 --- /dev/null +++ b/src/App/Components/Pages/Specialized/TankSection.razor @@ -0,0 +1,306 @@ +@using MeterVault.Core.Analysis +@using MeterVault.Infrastructure.Analysis +@inject NavigationManager Nav + +@* One tank (brief §7.5, D-54). "Now" and "the selected period" are two labelled parts, so a historical range never shows + today's contents as if they were then: now = the last dipstick as measured, the contents estimated from it with the + deliveries since, and the forecast — always a projection, hidden when the dipstick is too old; the period = usage per + bucket, deliveries in it, the contents at its end when it is over, burner runtime, the burn rate and the cost, each + with its status (an unknown cost is never 0). The actions done standing next to the tank sit in its header. *@ + + +
+

+ @Tank.Name +

+ + @S.Consumables_RecordTankLevel + @S.Consumables_RecordDelivery +
+ + + + @* ---------------------------------------------------------------- now *@ +
+ @S.Consumables_NowTitle + @S.Consumables_NowHint +
+ + + @if (Tank.LastDipstick is { } dipstick) + { + + } + else + { + + @S.Consumables_RecordTankLevel + + } + + + @if (Tank.EstimatedNow is { } estimate) + { + + @if (Tank.FillFraction is { } fill) + { + + + @Loc.F(S.Consumables_FillOfCapacity, Format.Quantity(fill * 100, "%"), Format.Quantity(Tank.Capacity, Tank.Unit)) + + } + + } + else + { + + } + + + + @S.Consumables_ForecastEmpty + @if (Tank.Forecast is { State: TankForecastState.Projected } forecast) + { +
+ @(forecast.EmptyOn is { } day ? Format.Date(day) : S.Consumables_ForecastBeyond) +
+ + @if (forecast.EmptyOn is { } empty && empty < Today) + { + @S.Consumables_ForecastPassed + } + } + else + { +
+ @S.Consumables_ForecastNone +
+ @ForecastReason(Tank.Forecast) + } +
+
+
+ + @* ---------------------------------------------------------------- the period *@ +
+ @S.Consumables_PeriodTitle + @Format.PeriodRange(Period) +
+ + @if (Tank.Usage is { IsPending: true }) + { + + } + else if (Period.NotYetOccurred || Period.HasNotStarted()) + { + + } + else + { + + + + + + + @S.Consumables_Delivered +
+ @if (Tank.Deliveries.Count > 0) + { + @Format.Quantity(Tank.DeliveredInPeriod, Tank.Unit) + } + else + { + @S.Consumables_NoDeliveriesShort + } +
+ @Loc.F(S.Consumables_DeliveriesCount, Tank.Deliveries.Count) +
+
+ @if (Tank.PeriodEndsBeforeNow) + { + + @if (Tank.AtPeriodEnd is { } end) + { + + } + else + { + + } + + } + @if (Tank.Runtime.Count > 0) + { + + r.Name)))" /> + + } + + @if (Tank.Rate is { } rate) + { + + } + else + { + + } + + + + +
+ + @if (Tank.Usage is { } usage && (usage.Total.Status == BucketStatus.Missing)) + { + + @if (usage.Availability is null) + { + @S.Consumables_NoUsageNextStep + } + + } + else if (Tank.Usage is { } series) + { + @Loc.F(S.Consumables_UsageChartTitle, Tank.Unit) + +
+ +
+ } + + @S.Consumables_DeliveriesInPeriod + @if (Tank.Deliveries.Count == 0) + { + @S.Consumables_NoDeliveries + } + else + { +
+ + + @S.Common_Date@S.Common_Amount + + + @foreach (var delivery in Tank.Deliveries) + { + + @Format.Date(LocalDate(delivery.Time)) + @Format.Quantity(delivery.Amount, delivery.Unit ?? Tank.Unit) + + } + + +
+ } + } +
+ +@code { + /// The tank's figures and series, built inside the page's load. + [Parameter, EditorRequired] + public TankView View { get; set; } = null!; + + /// The page's analysis state; links carry it. + [Parameter, EditorRequired] + public AnalysisQuery Query { get; set; } = null!; + + /// The resolved period the figures are for. + [Parameter, EditorRequired] + public ResolvedPeriod Period { get; set; } = null!; + + /// The reader's problems about this tank and its burners (D-53). + [Parameter] + public IReadOnlyList Problems { get; set; } = []; + + /// The instance currency (D-43). + [Parameter, EditorRequired] + public string Currency { get; set; } = "EUR"; + + /// Loads again (for "being prepared"). + [Parameter] + public EventCallback OnRefresh { get; set; } + + private TankAnalysis Tank => View.Tank; + + private IReadOnlyList Buckets => View.Buckets; + + private IReadOnlyList? Pairs => View.Pairs; + + private DateOnly Today => PeriodResolver.LocalDate(Period.Now, Period.Zone); + + private string? ChangeCaption => Query.Comparison.Kind == ComparisonKind.None ? null : Query.Comparison.Display(); + + /// The runtime change over the matched coverage, when there is one burner. + private Change? RuntimeChange => Tank.Runtime.Count == 1 ? Tank.Runtime[0].Comparison?.Change : null; + + /// The change chip only when a comparison was asked for. + private Change? ChangeOf(Change? change) => Query.Comparison.Kind == ComparisonKind.None ? null : change ?? Change.Unavailable; + + private DateOnly LocalDate(DateTimeOffset instant) => PeriodResolver.LocalDate(instant, Period.Zone); + + private string DipstickCaption(TankDipstick dipstick) + { + var date = Format.Date(LocalDate(dipstick.Time)); + var showReading = dipstick.IsCalibrated + || (dipstick.ReadingUnit is not null && !string.Equals(dipstick.ReadingUnit, Tank.Unit, StringComparison.OrdinalIgnoreCase)); + return showReading + ? Loc.F(S.Consumables_DipstickOnReading, date, Format.Quantity(dipstick.Reading, dipstick.ReadingUnit)) + : Loc.F(S.Consumables_DipstickOn, date); + } + + /// "Dipstick of 1 May 2026 plus 4,000 L delivered since; use since then is not deducted." + private string ContentsCaption(TankContents contents) + { + var date = Format.Date(LocalDate(contents.Dipstick.Time)); + return contents.DeliveriesSince > 0 + ? Loc.F(S.Consumables_ContentsWithDeliveries, date, Format.Quantity(contents.DeliveredSince, Tank.Unit)) + : Loc.F(S.Consumables_ContentsNoDeliveries, date); + } + + private string PerDayText(TankForecast forecast) => + forecast.PerDay is { } perDay ? Loc.F(S.Consumables_PerDay, Format.Quantity(perDay, Tank.Unit)) : string.Empty; + + private string ForecastReason(TankForecast forecast) => forecast.State switch + { + TankForecastState.DipstickTooOld => Loc.F(S.Consumables_ForecastTooOld, forecast.DipstickAgeDays ?? 0, TankForecast.MaxDipstickAgeDays), + TankForecastState.NotEnoughHistory => Loc.F(S.Consumables_ForecastShort, TankForecast.MinBasisDays), + TankForecastState.NoUse => S.Consumables_ForecastNoUse, + _ => S.Consumables_ForecastNoDipstick, + }; + + /// The fill bar's colour: the tank's own thresholds when set, else 15 % / 30 % of the capacity. + private Color FillColor(double volume) + { + var low = Tank.LowThreshold ?? Tank.Capacity * 0.15; + var reorder = Tank.ReorderThreshold ?? Tank.Capacity * 0.30; + return volume < low ? Color.Error : volume < reorder ? Color.Warning : Color.Success; + } + + private string? LatestHref(AnalysisSeries usage) => + AnalysisNavigation.LatestData(Query, usage.Availability) is { } latest ? AnalysisLinks.Consumables(latest) : null; + + /// + /// The next finer period on this page (D-51); for data too coarse to cut finer (dipsticks every few weeks), the tank's + /// normalized rows of that bucket. + /// + private string DrillHref(AnalysisBucket bucket, AnalysisSeries usage) => + AnalysisNavigation.DrillInto(Query, bucket, usage.Resolution) is { } next + ? AnalysisLinks.Consumables(next) + : AnalysisNavigation.NormalizedData(Tank.MeterId, Query, bucket); + + private void Drill(AnalysisBucket bucket, AnalysisSeries usage) => Nav.NavigateTo(DrillHref(bucket, usage)); +} diff --git a/src/App/Components/Pages/Trends.razor b/src/App/Components/Pages/Trends.razor index bffba6d..08fb3c9 100644 --- a/src/App/Components/Pages/Trends.razor +++ b/src/App/Components/Pages/Trends.razor @@ -1,60 +1,238 @@ @page "/trends" -@inject DashboardService Dash +@using MeterVault.App.AnalysisPage +@using MeterVault.App.Components.Pages.AnalysisPage +@using MeterVault.Core.Analysis +@using MeterVault.Infrastructure.Analysis +@using MeterVault.Infrastructure.Persistence +@using Microsoft.EntityFrameworkCore +@implements IDisposable +@inject NavigationManager Nav +@inject InstanceClock Clock +@inject AnalysisPeriods Periods +@inject AnalysisReader Reader +@inject CostReader Costs +@inject IDbContextFactory Db +@inject ILogger Logger -MeterVault — @S.Nav_Trends +@* The Analysis page (brief §7.4; the route stays /trends): one place to explore everything, an energy type, a cost + category, one meter or up to six meters side by side — by a quantity or by cost, over any period, against the previous + year or a named year, down to the finest bucket the data resolves. Every choice is part of the address (replace), so + reload, Back and a shared link restore it. Quantities come from the shared analysis reader, costs from the cost reader: + the same figures as the meter and energy type pages and the Overview, manual costs included once. *@ -@S.Trends_Title + + + + + + @if (ScopeHref is { } href) + { + @ScopeHrefText + } + + - -
- - @S.Common_RangeLast12Months - @S.Common_RangeLast24Months - @S.Trends_RangeLast48Months - - @S.Trends_Apply -
+@foreach (var notice in Notices.Where(n => !_dismissed.Contains(n))) +{ + + @notice + +} - @if (_loading) + + @if (_options is not null && _selection is not null) { - - } - else - { - - - @Loc.F(S.Trends_TotalOverRange, Format.Euro(_points.Sum(p => p.Cost))) - + } + + @if (YearOptions.Count > 0) + { +
+ + @foreach (var year in YearOptions) + { + @year.ToString(System.Globalization.CultureInfo.CurrentCulture) + } + +
+ } +
+ + + + @code { - private int _months = 24; + /// The history defaults (D-02, A-13): the last 12 months, automatic buckets, against the previous year; the portfolio. + private static readonly AnalysisDefaults Defaults = AnalysisDefaults.History; - // Starts idle: LoadAsync is the one that sets it. Starting busy made the very first load bail out - // on its own guard, so the page never got past the progress bar and Apply stayed disabled. - private bool _loading; - private IReadOnlyList _points = []; + private readonly LoadSequencer _loads = new(); + private readonly LoadState _state = new(); + private readonly HashSet _dismissed = []; - protected override Task OnInitializedAsync() => LoadAsync(); + private AnalysisPageOptions? _options; + private AnalysisQuery _query = AnalysisQuery.Default(Defaults); + private AnalysisSelection? _selection; + private AnalysisQuery _shown = AnalysisQuery.Default(Defaults); + private bool _loaded; - private async Task LoadAsync() + /// One committed load: the options it was read against, the address shown and the view. + private sealed record PageState(AnalysisPageOptions Options, AnalysisQuery Shown, AnalysisPageView View); + + protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged; + + protected override Task OnParametersSetAsync() => ReloadIfChangedAsync(); + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () => { - if (_loading) + await ReloadIfChangedAsync(); + StateHasChanged(); + }); + + private async Task ReloadIfChangedAsync() + { + var query = AnalysisQuery.Parse(Nav.Uri, Defaults); + if (_loaded && query == _query) { - return; // guard against overlapping loads + return; } - _loading = true; - try + _loaded = true; + _query = query; + Select(); + await LoadAsync(query); + } + + /// Reads the address against the options at once, so the selectors follow a change before its data arrives. + private void Select() + { + _selection = _options is null ? null : AnalysisSelection.Resolve(_query, _options); + _shown = _selection?.Shown(_query) ?? _query; + } + + private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token => + { + var options = _options ?? await AnalysisPageOptions.LoadAsync(Db, Reader, token); + var selection = AnalysisSelection.Resolve(query, options); + var view = await new AnalysisPageLoader(Reader, Costs, Periods).LoadAsync(query, selection, options, Clock.Now, token); + if (_options is null) { - var asOf = DateOnly.FromDateTime(DateTime.UtcNow); - var from = asOf.AddMonths(-_months); - _points = await Dash.GetMonthlyTrendAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1)); + _options = options; + if (query == _query) + { + Select(); + } } - finally + + return new PageState(options, selection.Shown(query), view); + }, Logger); + + private async Task RetryAsync() + { + // A retry reads the meters, types and categories again too: one of them may be what failed. + _options = null; + Select(); + await LoadAsync(_query); + } + + /// A selector or the toolbar changed the state: written into the address, replacing the entry (D-46). + private Task ApplyAsync(AnalysisQuery next) + { + AnalysisNavigation.Replace(Nav, next, Defaults); + return Task.CompletedTask; + } + + /// The notices of the address (invalid keys) and of the selection (a metric that does not apply, meters gone). + private IEnumerable Notices + { + get { - _loading = false; + foreach (var notice in _query.Notices) + { + yield return notice.Kind.Display(); + } + + foreach (var notice in _selection?.Notices ?? []) + { + yield return notice.Kind switch + { + AnalysisPageNoticeKind.MetricNotAvailable when notice.Shown is { } shown => + Loc.F(S.Analysis_NoticeMetricNotAvailable, notice.Requested?.Display() ?? string.Empty, shown.Display()), + AnalysisPageNoticeKind.MetricNotAvailable => Loc.F(S.Analysis_NoticeMetricNotShown, notice.Requested?.Display() ?? string.Empty), + _ => S.Analysis_NoticeUnknownMeters, + }; + } } } + + /// The CSV of what is shown (D-55): the readers' query, so the file holds the figures on screen. + private string? ExportHref => _selection is { Refusal: AnalysisPageRefusal.None } selection ? AnalysisLinks.Export(selection.ReadQuery(_query)) : null; + + /// The page the scope has of its own (an energy type, a meter), with the period. + private string? ScopeHref => _selection is not { Refusal: AnalysisPageRefusal.None } selection + ? null + : selection.Scope.Kind switch + { + QueryScopeKind.EnergyType => AnalysisLinks.EnergyType(selection.Scope.Id!.Value, AnalysisLinks.EnergyTabHistory, _shown), + QueryScopeKind.Meter => MeterLinks.Analysis(selection.Scope.Id!.Value, _shown), + _ => null, + }; + + private string ScopeHrefText => _selection?.Scope.Kind == QueryScopeKind.Meter ? S.Analysis_OpenMeter : S.Analysis_OpenEnergyType; + + /// + /// Calendar years to pick (the "compare two years" journey): from the first year with data to the current one. + /// Picking one shows that year; the comparison then offers the years before it. + /// + private IReadOnlyList YearOptions + { + get + { + var today = Clock.Today; + var first = _state.Value?.View.Availability?.FirstDay.Year ?? today.Year; + first = Math.Max(first, today.Year - 60); + return [.. Enumerable.Range(first, today.Year - first + 1).Reverse()]; + } + } + + /// The calendar year the period is, if it is one (a custom 1 Jan – 31 Dec, the previous year, the year to date). + private int? SelectedYear + { + get + { + var today = Clock.Today; + return _shown.Period switch + { + PeriodPreset.YearToDate => today.Year, + PeriodPreset.PreviousYear => today.Year - 1, + PeriodPreset.Custom when _shown.From is { Month: 1, Day: 1 } from && _shown.To is { Month: 12, Day: 31 } to && from.Year == to.Year => from.Year, + _ => null, + }; + } + } + + private Task OnYearChanged(int? year) + { + if (year is not { } chosen || chosen == SelectedYear) + { + return Task.CompletedTask; + } + + // The current year runs to date; a past year is the whole calendar year. + var next = chosen == Clock.Today.Year + ? _shown.WithPeriod(PeriodPreset.YearToDate) + : _shown.WithCustomRange(new DateOnly(chosen, 1, 1), new DateOnly(chosen, 12, 31)); + return ApplyAsync(next); + } + + public void Dispose() + { + Nav.LocationChanged -= OnLocationChanged; + _loads.Dispose(); + } } diff --git a/src/App/Components/Pages/Trends.razor.css b/src/App/Components/Pages/Trends.razor.css new file mode 100644 index 0000000..a5413da --- /dev/null +++ b/src/App/Components/Pages/Trends.razor.css @@ -0,0 +1,5 @@ +/* The calendar-year picker takes little room, so the period, interval and comparison keep theirs ("Automatisch (Monatlich)"). */ +.mv-analysis-year { flex: 0 1 150px; min-width: 130px; } +@media (max-width: 599.98px) { + .mv-analysis-year { flex: 1 1 100%; max-width: none; } +} diff --git a/src/App/Components/Routes.razor b/src/App/Components/Routes.razor index 105855d..78b8bff 100644 --- a/src/App/Components/Routes.razor +++ b/src/App/Components/Routes.razor @@ -1,6 +1,28 @@ - +@using MeterVault.App.Theme +@inject ThemeState Theme +@inject NavState NavState + + + +@code { + /// The theme the request's cookie named (App.razor reads it); dark without one. + [Parameter] + public bool DarkMode { get; set; } = ThemeState.DefaultIsDark; + + /// The raw navigation-groups cookie of the request (), or null. + [Parameter] + public string? NavGroups { get; set; } + + // Runs before anything below renders, in the prerender and again when the circuit starts: the scoped states of both + // start from what the request carried. + protected override void OnInitialized() + { + Theme.Initialize(DarkMode); + NavState.InitializeGroups(NavGroups); + } +} diff --git a/src/App/Components/Shared/Analysis/AnalysisBreadcrumbs.razor b/src/App/Components/Shared/Analysis/AnalysisBreadcrumbs.razor new file mode 100644 index 0000000..3a5d9be --- /dev/null +++ b/src/App/Components/Shared/Analysis/AnalysisBreadcrumbs.razor @@ -0,0 +1,60 @@ +@* Overview → energy type → meter (D-48, brief §3.1). Every link carries the page's period, bucket, comparison and + metric, so going up keeps the dates; the last crumb is the current page (aria-current), not a link. Names are user + data, shown as they are. MudBlazor's breadcrumb styles, our own markup: a localized landmark name and a real + current-page item instead of a disabled "#" link. *@ + + + +@code { + /// The current page's analysis state; its period travels with every link. + [Parameter] + public AnalysisQuery? Query { get; set; } + + /// The energy type the page is in (with ). + [Parameter] + public int? EnergyTypeId { get; set; } + + [Parameter] + public string? EnergyTypeName { get; set; } + + /// The meter the page is about (with ). + [Parameter] + public int? MeterId { get; set; } + + [Parameter] + public string? MeterName { get; set; } + + /// A label for the current page below the above (a specialized view); null when the last of them is the page. + [Parameter] + public string? Current { get; set; } + + private IReadOnlyList _trail = []; + + protected override void OnParametersSet() + { + (int, string)? type = EnergyTypeId is { } typeId ? (typeId, EnergyTypeName ?? string.Empty) : null; + (int, string)? meter = MeterId is { } meterId ? (meterId, MeterName ?? string.Empty) : null; + _trail = AnalysisNavigation.Breadcrumbs(Query, type, meter, Current); + } +} diff --git a/src/App/Components/Shared/Analysis/AnalysisChart.razor b/src/App/Components/Shared/Analysis/AnalysisChart.razor new file mode 100644 index 0000000..3ca2fb0 --- /dev/null +++ b/src/App/Components/Shared/Analysis/AnalysisChart.razor @@ -0,0 +1,188 @@ +@* The shared analysis chart (D-49, brief §8). It draws the buckets of a plan and a list of series: bars or lines with a + real zero baseline, unknown buckets as gaps (never zero, never joined, never smoothed), units and currency in the + axis and tooltip, labels with the year across years. A qualified bucket (partial, estimated, not fully priced) is + marked in its label and drawn faded or hollow, and the note under the chart sends the reader to the table, which says + why in words. Series of different units get a chart each — never two scales on one plot. Transparent, in the + theme's mode, and re-keyed on every theme or result change. A click on a bucket raises OnBucketClick (D-51). *@ + +@using ApexCharts +@implements IDisposable +@inject ThemeState ThemeState + +@if (_plan is null || !_plan.HasValues) +{ + @* Nothing to draw says why (brief §4.3, §9.6): coarser data than the buckets can hold (with the interval that shows + it), a cost without a price (the quantities are there), or no data — never "no data" for the first two. *@ + @switch (_plan?.EmptyReason) + { + case ChartEmptyReason.Unresolved: +
+ + @(Resolution is { } resolution ? Loc.F(S.AnalysisChart_OnlyCoarser, resolution.Display()) : S.AnalysisChart_OnlyCoarserUnknown) + + @if (Resolution is { } coarse && OnUseBucket.HasDelegate) + { + var size = BucketPlanner.MinimumSizeFor(coarse); + + @Loc.F(S.Toolbar_UseBucket, size.Display()) + + } +
+ break; + case ChartEmptyReason.NotPriced: + @Loc.F(S.AnalysisChart_CostUnavailable, _plan!.EmptyStatus ?? CostStatus.NotPriced.Display()) + break; + default: + @(EmptyText ?? S.Common_NoDataInRange) + break; + } +} +else +{ +
+ @foreach (var (panel, options) in _panels) + { +
+ @if (_panels.Count > 1 && panel.Unit.Length > 0) + { +
@panel.Unit
+ } + + @foreach (var series in panel.Series) + { + + } + + @if (panel.HasMarked) + { +
@S.AnalysisChart_MarkedNote
+ } + @if (panel.HasGaps) + { +
@S.AnalysisChart_GapNote
+ } +
+ } +
+} + +@code { + /// The buckets of the plan (), oldest first. + [Parameter, EditorRequired] + public IReadOnlyList Buckets { get; set; } = []; + + /// The series; an overlay () pairs with the buckets by index. + [Parameter, EditorRequired] + public IReadOnlyList Series { get; set; } = []; + + /// The comparison buckets paired with (A-10): an overlay's tooltip names its own bucket. + [Parameter] + public IReadOnlyList? ComparisonPairs { get; set; } + + /// What the chart shows, for its accessible name ("Consumption of Haus"); the series names by default. + [Parameter] + public string? Title { get; set; } + + /// The text shown when nothing can be drawn because there is no data (coarser data and missing prices say so themselves). + [Parameter] + public string? EmptyText { get; set; } + + /// The coarsest resolution of the charted data (): named when the buckets are finer than it. + [Parameter] + public ResolutionClass? Resolution { get; set; } + + /// Applies a coarser interval — offered with the resolution when the buckets are finer than the data. + [Parameter] + public EventCallback OnUseBucket { get; set; } + + [Parameter] + public int Height { get; set; } = 320; + + /// A bucket was clicked (a bar, a marker or its axis label): drill down (D-51). + [Parameter] + public EventCallback OnBucketClick { get; set; } + + private AnalysisChartPlan? _plan; + private List<(ChartPanel Panel, ApexChartOptions Options)> _panels = []; + private long _generation; + private object? _builtFrom; + private EventCallback> _onSelect; + private EventCallback> _onLabel; + + protected override void OnInitialized() => ThemeState.Changed += OnThemeChanged; + + protected override void OnParametersSet() + { + _onSelect = OnBucketClick.HasDelegate ? EventCallback.Factory.Create>(this, OnSelectAsync) : default; + _onLabel = OnBucketClick.HasDelegate ? EventCallback.Factory.Create>(this, OnLabelAsync) : default; + + // Rebuilt (and the chart re-keyed) only for a new result: the library renders its options once. + var source = (Buckets, Series, ComparisonPairs, System.Globalization.CultureInfo.CurrentCulture.Name); + if (!Equals(_builtFrom, source)) + { + _builtFrom = source; + Rebuild(); + } + } + + private void Rebuild() + { + var palette = ChartPalette.For(ThemeState.IsDark); + _plan = AnalysisChartPlan.Build(Buckets, Series, palette, ComparisonPairs); + _panels = [.. _plan.Panels.Select(p => (p, AnalysisChartOptions.Build(p, palette, System.Globalization.CultureInfo.CurrentCulture)))]; + _generation++; + } + + private void OnThemeChanged() => _ = InvokeAsync(() => + { + Rebuild(); + StateHasChanged(); + }); + + private static object LabelOf(ChartPoint point) => point.Label; + + private static decimal? ValueOf(ChartPoint point) => point.Value; + + private static string FillOf(ChartPoint point) => point.FillColor!; + + private static SeriesStroke StrokeOf(ChartPanelSeries series) => + new() { Width = series.StrokeWidth, Color = series.Color, DashSpace = series.DashSpace }; + + private static void Attach(DataPoint point) + { + if (point.Items?.FirstOrDefault() is { } source) + { + point.Extra = new ChartPointExtra(source.Tooltip); + } + } + + private string AriaLabelOf(ChartPanel panel) + { + var what = Title ?? string.Join(", ", panel.Series.Select(s => s.Name)); + return Loc.F(S.AnalysisChart_AriaLabel, what); + } + + private Task OnSelectAsync(SelectedData selected) => RaiseAsync(selected.DataPointIndex); + + private Task OnLabelAsync(XAxisLabelClicked clicked) => RaiseAsync(clicked.LabelIndex); + + private async Task RaiseAsync(int index) + { + if (index >= 0 && index < Buckets.Count) + { + await OnBucketClick.InvokeAsync(Buckets[index]); + } + } + + public void Dispose() => ThemeState.Changed -= OnThemeChanged; +} diff --git a/src/App/Components/Shared/Analysis/AnalysisTable.razor b/src/App/Components/Shared/Analysis/AnalysisTable.razor new file mode 100644 index 0000000..514ac6a --- /dev/null +++ b/src/App/Components/Shared/Analysis/AnalysisTable.razor @@ -0,0 +1,132 @@ +@* The accessible alternative to the chart (brief §7.2): one row per bucket with the period label, each series' value + with its unit, the status in words (availability, reason, provenance), the optional cost and price coverage, the + optional comparison (with the bucket it is compared with) and the change, a total row, and a drill-down per row. + A wide table scrolls inside its own container, never the page. *@ + +@if (_model is not null && _model.Rows.Count > 0) +{ +
+ + @if (!string.IsNullOrWhiteSpace(Caption)) + { + @Caption + } + + + @S.AnalysisTable_Period + @foreach (var column in _model.Columns) + { + + @column.Header + @if (column.SubHeader is not null) + { +
@column.SubHeader
+ } + + } + @if (HasDrill) + { + @S.Common_Actions + } + + + + @foreach (var row in _model.Rows) + { + + @row.Label + @for (var i = 0; i < row.Cells.Count; i++) + { + var cell = row.Cells[i]; + var numeric = _model.Columns[i].IsNumeric; + + @cell.Text + @if (cell.Secondary is not null) + { +
@cell.Secondary
+ } + + } + @if (HasDrill) + { + + @if (row.Bucket is { } bucket) + { + var href = DrillHref?.Invoke(bucket); + var label = Loc.F(S.AnalysisTable_DrillLabel, row.Label); + if (href is not null) + { + + } + else if (OnDrill.HasDelegate) + { + + } + } + + } + + } + +
+
+ @if (Series.Any(s => !s.IsAdditive)) + { + @S.AnalysisTable_NotAdditive + } +} + +@code { + /// The buckets of the plan, oldest first. + [Parameter, EditorRequired] + public IReadOnlyList Buckets { get; set; } = []; + + /// The series (, , …). + [Parameter, EditorRequired] + public IReadOnlyList Series { get; set; } = []; + + /// The comparison buckets paired with : each row names the bucket it is compared with. + [Parameter] + public IReadOnlyList? ComparisonPairs { get; set; } + + [Parameter] + public bool IncludeTotal { get; set; } = true; + + /// The drill-down address of a row ( written as a URL); null for none. + [Parameter] + public Func? DrillHref { get; set; } + + /// A row's drill-down as an action, when no address is given. + [Parameter] + public EventCallback OnDrill { get; set; } + + /// The table's accessible name (also its caption for screen readers). + [Parameter] + public string? Caption { get; set; } + + private AnalysisTableModel? _model; + private object? _builtFrom; + + private bool HasDrill => DrillHref is not null || OnDrill.HasDelegate; + + protected override void OnParametersSet() + { + var source = (Buckets, Series, ComparisonPairs, IncludeTotal, System.Globalization.CultureInfo.CurrentCulture.Name); + if (!Equals(_builtFrom, source)) + { + _builtFrom = source; + _model = AnalysisTableModel.Build(Buckets, Series, ComparisonPairs, IncludeTotal); + } + } + + private static string? RowClass(AnalysisTableRow row) => + row.IsTotal ? "mv-row-total" : row.IsQualified ? "mv-row-qualified" : null; + + private static string? CellClass(AnalysisTableCell cell, bool numeric) + { + var classes = string.Join(' ', new[] { numeric ? "mv-num" : null, cell.CssClass, cell.IsUnknown ? "mv-unknown" : null }.Where(c => c is not null)); + return classes.Length > 0 ? classes : null; + } +} diff --git a/src/App/Components/Shared/Analysis/AttentionList.razor b/src/App/Components/Shared/Analysis/AttentionList.razor new file mode 100644 index 0000000..a04178a --- /dev/null +++ b/src/App/Components/Shared/Analysis/AttentionList.razor @@ -0,0 +1,95 @@ +@* Attention items (D-53, brief §7.1): the readers' problems and cost items as one-liners in the reader's language, + each with its one targeted action — a missing price opens the prefilled tariff editor, a calculation to fix the + meter's Calculation tab, a stale source its Sources tab, rows after now its Normalized data, a possible overlap the + energy type's Meters tab. Severity is an icon shape and a hidden word, not only a colour. Nothing is rendered when + there is nothing to say. *@ + +@if (_items.Count > 0) +{ +
+ @if (ShowTitle) + { + @(Title ?? S.Attention_Title) + } + else + { + @(Title ?? S.Attention_Title) + } +
    + @foreach (var item in Visible) + { +
  • +
  • + } +
+ @if (MaxItems is { } max && _items.Count > max) + { + + @(_expanded ? S.Attention_ShowFewer : Loc.F(S.Attention_ShowAll, _items.Count)) + + } +
+} + +@code { + /// The quantity reader's problems (, ). + [Parameter] + public IEnumerable? Problems { get; set; } + + /// The cost reader's items (). + [Parameter] + public IEnumerable? CostAttention { get; set; } + + /// The meter and energy type names (); fallbacks ("Meter #12") without them. + [Parameter] + public AttentionNames? Names { get; set; } + + /// The page's analysis state: links into meter pages carry its period. + [Parameter] + public AnalysisQuery? Query { get; set; } + + /// The heading; "Needs attention" by default. + [Parameter] + public string? Title { get; set; } + + /// Shows the heading (it is always there for screen readers). + [Parameter] + public bool ShowTitle { get; set; } = true; + + /// Shows this many items and a "show all" button for the rest. + [Parameter] + public int? MaxItems { get; set; } + + [Parameter] + public string? Class { get; set; } + + private readonly string _headingId = "mv-attention-" + Guid.NewGuid().ToString("N")[..8]; + private IReadOnlyList _items = []; + private bool _expanded; + + private IEnumerable Visible => MaxItems is { } max && !_expanded ? _items.Take(max) : _items; + + protected override void OnParametersSet() => + _items = AttentionItems.Build(Problems, CostAttention, Names ?? new AttentionNames(), Query); + + private static string IconOf(AttentionSeverity severity) => severity switch + { + AttentionSeverity.Error => Icons.Material.Outlined.ErrorOutline, + AttentionSeverity.Warning => Icons.Material.Outlined.WarningAmber, + _ => Icons.Material.Outlined.Info, + }; + + private static string SeverityWord(AttentionSeverity severity) => severity switch + { + AttentionSeverity.Error => S.Attention_SeverityError, + AttentionSeverity.Warning => S.Attention_SeverityWarning, + _ => S.Attention_SeverityInfo, + }; +} diff --git a/src/App/Components/Shared/Analysis/ChangeChip.razor b/src/App/Components/Shared/Analysis/ChangeChip.razor new file mode 100644 index 0000000..be1f703 --- /dev/null +++ b/src/App/Components/Shared/Analysis/ChangeChip.razor @@ -0,0 +1,51 @@ +@* A change (D-08): the absolute difference always, the percentage only where it applies ("percentage not applicable" + otherwise), an arrow and the direction in words — never colour alone. The colour follows the metric's polarity: more + consumption or cost is bad, more generation is good, a net result or a credit is neutral. Theme palette colours. *@ + + + + +@code { + /// The change (; pass a display-sized tolerance, e.g. half a cent for money). + [Parameter, EditorRequired] + public Change Change { get; set; } = Change.Unavailable; + + /// Whether a rise is good news (). + [Parameter] + public ChangePolarity Polarity { get; set; } = ChangePolarity.Neutral; + + /// Formats the size of the difference: a quantity with its unit, or money (). + [Parameter] + public Func? FormatMagnitude { get; set; } + + /// What it is compared with ("vs. same period last year"). + [Parameter] + public string? Caption { get; set; } + + [Parameter] + public string? Class { get; set; } + + private ChangeTone _tone; + private string _words = string.Empty; + + private string ArrowIcon => Change.Direction switch + { + > 0 => Icons.Material.Filled.ArrowUpward, + < 0 => Icons.Material.Filled.ArrowDownward, + _ => Icons.Material.Filled.Remove, + }; + + private string TitleText => string.IsNullOrWhiteSpace(Caption) ? _words : _words + " " + Caption; + + protected override void OnParametersSet() + { + _tone = ChangeDisplay.Tone(Change, Polarity); + _words = ChangeDisplay.Words(Change, FormatMagnitude ?? (v => Format.Number(v, 2))); + } +} diff --git a/src/App/Components/Shared/Analysis/ComparisonSummary.razor b/src/App/Components/Shared/Analysis/ComparisonSummary.razor new file mode 100644 index 0000000..44b4181 --- /dev/null +++ b/src/App/Components/Shared/Analysis/ComparisonSummary.razor @@ -0,0 +1,52 @@ +@* Which dates are compared (D-06, D-07, brief §4.2): the current period and the comparison as requested, and the + stretch both actually cover, over which a change is stated. When nothing matches, the comparison is "not + comparable": absolute values only, no percentage. A comparison that does not apply says why. *@ + +@if (Resolution is { } resolution) +{ +
+ @if (resolution.Period is { } comparison) + { +
@Loc.F(S.Comparison_Ranges, CurrentRange, Format.DateRange(comparison.FirstDay, comparison.LastDay))
+ @if (Matched is { } matched) + { + @if (!matched.IsComparable) + { +
+
+ } + else if (matched.Current is { } current && matched.Comparison is { } other) + { +
+ @Loc.F(S.Comparison_Matched, Format.DateRange(current.FirstDay, current.LastDay), Format.DateRange(other.FirstDay, other.LastDay))@(matched.IsContiguous ? string.Empty : " " + S.Comparison_WithGaps) +
+ } + } + } + else if (resolution.Reason != ComparisonUnavailableReason.NotRequested) + { +
@Loc.F(S.Comparison_Unavailable, resolution.Reason.Display())
+ } +
+} + +@code { + /// The current period (its effective dates are shown). + [Parameter, EditorRequired] + public ResolvedPeriod Period { get; set; } = null!; + + /// How the comparison was resolved (, ). + [Parameter] + public ComparisonResolution? Resolution { get; set; } + + /// The coverage both periods share (); null when not read. + [Parameter] + public MeterVault.Core.Analysis.Coverage.MatchedCoverageResult? Matched { get; set; } + + [Parameter] + public string? Class { get; set; } + + private string CurrentRange => Format.PeriodRange(Period); +} diff --git a/src/App/Components/Shared/Analysis/EmptyPeriodState.razor b/src/App/Components/Shared/Analysis/EmptyPeriodState.razor new file mode 100644 index 0000000..0e8fced --- /dev/null +++ b/src/App/Components/Shared/Analysis/EmptyPeriodState.razor @@ -0,0 +1,60 @@ +@* The empty states of an analysis panel (brief §4.3): nothing in the chosen period while older data exists — say so, + show the dates that do have data and offer to go there; a period that has not started yet; or no data at all, with + the page's mode-specific next step as its content. *@ + +
+
+ +@code { + /// What the scope has data for (, ); null when nothing at all. + [Parameter] + public MeterVault.Core.Analysis.Coverage.AvailableRange? Availability { get; set; } + + /// The latest data as an address ( written for the page). + [Parameter] + public string? LatestHref { get; set; } + + /// The latest data as an action, when no address is given. + [Parameter] + public EventCallback OnGoToLatest { get; set; } + + /// The chosen period lies entirely after now (D-04). + [Parameter] + public bool NotYetOccurred { get; set; } + + /// The next step when there is no data at all (mode-specific: add a first reading, connect a source, …). + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string? Class { get; set; } +} diff --git a/src/App/Components/Shared/Analysis/LoadPanel.razor b/src/App/Components/Shared/Analysis/LoadPanel.razor new file mode 100644 index 0000000..289075d --- /dev/null +++ b/src/App/Components/Shared/Analysis/LoadPanel.razor @@ -0,0 +1,56 @@ +@typeparam TValue where TValue : class +@* One loading panel over a LoadState (the LoadSequencer page pattern): a placeholder on the first load, a local error + with Retry when nothing could be loaded, and otherwise the value — dimmed under a progress bar while a newer one + loads, and under a "figures are from before" error when the refresh failed. Everything inside comes from the one + committed value, so a title never sits above another selection's chart. *@ + +@if (State.Value is { } value) +{ + @if (State.Error is not null) + { + + } + @ChildContent(value) +} +else if (State.Error is not null) +{ + +} +else if (State.IsLoading) +{ +
+ @if (Placeholder is not null) + { + @Placeholder + } + else + { + + + } +
+} + +@code { + /// The panel's load state (). + [Parameter, EditorRequired] + public LoadState State { get; set; } = null!; + + /// Renders the committed value. + [Parameter, EditorRequired] + public RenderFragment ChildContent { get; set; } = null!; + + /// Loads again (the page's LoadAsync(_query)). + [Parameter] + public EventCallback OnRetry { get; set; } + + /// What the first load shows instead of the default bar and block. + [Parameter] + public RenderFragment? Placeholder { get; set; } + + [Parameter] + public int PlaceholderHeight { get; set; } = 240; + + [Parameter] + public string? Class { get; set; } +} diff --git a/src/App/Components/Shared/Analysis/MetricCard.razor b/src/App/Components/Shared/Analysis/MetricCard.razor new file mode 100644 index 0000000..254960e --- /dev/null +++ b/src/App/Components/Shared/Analysis/MetricCard.razor @@ -0,0 +1,125 @@ +@* One period figure (brief §7.1, §7.2): a title, the value — a quantity with its unit or money — or, when there is no + number, the status in words (never a fabricated zero); a status chip whenever the figure is not a plain complete + value; a caption; the change against the comparison; and an optional link to where the figure comes from. *@ + + + @Title + @if (Loading) + { + + } + else + { +
+ @_valueText + @if (_chipText is not null) + { + + @_chipText + + } +
+ @if (!string.IsNullOrWhiteSpace(Caption)) + { + @Caption + } + @if (Change is not null) + { +
+ +
+ } + @ChildContent + @if (Href is not null) + { + @(LinkText ?? S.MetricCard_Open) + } + } +
+ +@code { + /// What the figure is ("Consumption this month"). + [Parameter, EditorRequired] + public string Title { get; set; } = string.Empty; + + /// A quantity figure (a total, a bucket); shown with . + [Parameter] + public BucketValue? Value { get; set; } + + /// The quantity's unit. + [Parameter] + public string? Unit { get; set; } + + /// A cost figure instead of a quantity; shown in . + [Parameter] + public CostAmount? Cost { get; set; } + + /// The ISO currency of (). + [Parameter] + public string? Currency { get; set; } + + /// A line under the value (the dates, the basis). + [Parameter] + public string? Caption { get; set; } + + /// The change against the comparison; no chip when null. + [Parameter] + public Change? Change { get; set; } + + [Parameter] + public ChangePolarity Polarity { get; set; } = ChangePolarity.Neutral; + + /// What the change compares with ("vs. same period last year"). + [Parameter] + public string? ChangeCaption { get; set; } + + /// Where the figure can be explored. + [Parameter] + public string? Href { get; set; } + + [Parameter] + public string? LinkText { get; set; } + + /// Shows a placeholder instead of the value. + [Parameter] + public bool Loading { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string? Class { get; set; } + + private FigureStatus? _status; + private string? _chipText; + private string _valueText = Format.Unknown; + private Func _formatMagnitude = v => Format.Number(v, 2); + + protected override void OnParametersSet() + { + if (Cost is { } cost) + { + _status = FigureText.Of(cost); + _valueText = _status.IsKnown ? Format.Money(cost.Cost, Currency) : _status.Status; + _formatMagnitude = v => Format.Money(v, Currency); + + // Fully priced but over incomplete quantities: the chip names the quantities' state, not "priced". + _chipText = !_status.IsKnown || _status.IsComplete + ? null + : cost.Status == CostStatus.Priced && cost.Availability != BucketStatus.Available ? cost.Availability.Display() : _status.Status; + } + else if (Value is { } value) + { + _status = FigureText.Of(value); + _valueText = _status.IsKnown ? Format.Quantity(value.Value, Unit) : _status.Status; + _formatMagnitude = v => Format.Quantity(v, Unit); + _chipText = _status.IsKnown && !_status.IsComplete ? _status.Status : null; + } + else + { + _status = null; + _chipText = null; + _valueText = Format.Unknown; + } + } +} diff --git a/src/App/Components/Shared/Analysis/PageHeader.razor b/src/App/Components/Shared/Analysis/PageHeader.razor new file mode 100644 index 0000000..95a3a0d --- /dev/null +++ b/src/App/Components/Shared/Analysis/PageHeader.razor @@ -0,0 +1,71 @@ +@* The shared page header (brief §8): breadcrumbs, the title as the page's one h1 (so Routes' FocusOnNavigate lands on + it), chips beside the title, an actions slot, and an optional description. Wraps predictably down to 360px: the + actions move below the title, and long (German) names break instead of pushing the page wider. *@ + +@if (SetsDocumentTitle) +{ + MeterVault — @(DocumentTitle ?? Title) +} + +
+ @if (Breadcrumbs is not null) + { + @Breadcrumbs + } +
+
+ @* MudText has no tag override in this MudBlazor version: a plain h1 wearing the h4 typography. *@ +

@Title

+ @if (Chips is not null) + { +
@Chips
+ } +
+ @if (Actions is not null) + { +
@Actions
+ } +
+ @if (!string.IsNullOrWhiteSpace(Description)) + { + @Description + } + @ChildContent +
+ +@code { + /// The page title (h1). A name from the database is shown as it is. + [Parameter, EditorRequired] + public string Title { get; set; } = string.Empty; + + /// The browser tab's title after "MeterVault — "; when null. + [Parameter] + public string? DocumentTitle { get; set; } + + /// False leaves the browser tab's title to the page. + [Parameter] + public bool SetsDocumentTitle { get; set; } = true; + + /// One line under the title saying what the page is for. + [Parameter] + public string? Description { get; set; } + + /// The breadcrumb trail, usually <AnalysisBreadcrumbs … />. + [Parameter] + public RenderFragment? Breadcrumbs { get; set; } + + /// Chips beside the title (energy type, mode, "Retired"). + [Parameter] + public RenderFragment? Chips { get; set; } + + /// The page's primary actions (buttons, menus), right of the title on wide screens, below it on narrow ones. + [Parameter] + public RenderFragment? Actions { get; set; } + + /// Anything else that belongs to the header (a notice, the update banner). + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string? Class { get; set; } +} diff --git a/src/App/Components/Shared/Analysis/PanelError.razor b/src/App/Components/Shared/Analysis/PanelError.razor new file mode 100644 index 0000000..adb4265 --- /dev/null +++ b/src/App/Components/Shared/Analysis/PanelError.razor @@ -0,0 +1,31 @@ +@* A panel's own error with Retry (brief §4.3, §8): the page and the other panels keep working. It says whether the + figures still shown are from before (stale but visible) or whether there is nothing to show. The exception itself is + logged by the LoadSequencer, never shown. *@ + + +
+ @(Message ?? (HasStaleValue ? S.PanelError_Stale : S.PanelError_Load)) + @if (OnRetry.HasDelegate) + { + @S.Common_Retry + } +
+
+ +@code { + /// The previous figures are still shown below; the message says they are from before. + [Parameter] + public bool HasStaleValue { get; set; } + + /// Loads the panel again. + [Parameter] + public EventCallback OnRetry { get; set; } + + /// A specific message instead of the generic one. + [Parameter] + public string? Message { get; set; } + + [Parameter] + public string? Class { get; set; } +} diff --git a/src/App/Components/Shared/Analysis/PendingState.razor b/src/App/Components/Shared/Analysis/PendingState.razor new file mode 100644 index 0000000..c8726cf --- /dev/null +++ b/src/App/Components/Shared/Analysis/PendingState.razor @@ -0,0 +1,24 @@ +@* "Analysis being prepared" (D-16): the meter's history is being rebuilt after an upgrade or a zone change. Never + "no data" — nothing is lost, and the figures appear once the rebuild is done. *@ + + +
+
+ @S.Pending_Title +
@S.Pending_Text
+
+ @if (OnRefresh.HasDelegate) + { + @S.Pending_Refresh + } +
+
+ +@code { + /// Checks again (reloads the panel). + [Parameter] + public EventCallback OnRefresh { get; set; } + + [Parameter] + public string? Class { get; set; } +} diff --git a/src/App/Components/Shared/Analysis/PeriodToolbar.razor b/src/App/Components/Shared/Analysis/PeriodToolbar.razor new file mode 100644 index 0000000..98cdb2f --- /dev/null +++ b/src/App/Components/Shared/Analysis/PeriodToolbar.razor @@ -0,0 +1,378 @@ +@* The shared period toolbar (brief §4.1/4.2, D-02 – D-06). It never navigates by itself: every choice raises + QueryChanged with the new AnalysisQuery, and the page writes it into its address with replace:true + (AnalysisNavigation.Replace, D-46). Presets apply at once; custom dates apply with the one Apply button, enabled only + while the two dates form a valid range. The effective dates stand under the preset, the time zone behind the info + button. Controls wrap onto full-width rows on a phone. *@ + +
+
+ + @foreach (var preset in PresetOptions) + { + @preset.Display() + } + +
+ + @if (Period is not null) + { + + + + + +
+
@Loc.F(S.Toolbar_TimeZone, Period.Zone.Id)
+ @if (ShowsRecords) + { +
@S.Toolbar_RecordsWholeRange
+ } + else + { + @if (Period.IsToDate) + { +
@Loc.F(S.Toolbar_UpToNow, NowText)
+ } + @if (Period.ExtendsPastNow) + { +
@S.Toolbar_FutureNotCounted
+ } + } +
+
+
+ } + + @if (_customMode) + { +
+ + + @S.Toolbar_Apply + @if (RangeIsBackwards) + { + @S.Toolbar_InvalidRange + } +
+ } + + @if (ShowBucket) + { +
+ + @foreach (var size in BucketOptions) + { + var tooMany = ExceedsLimit(size); + + @BucketText(size)@(tooMany ? " " + S.Toolbar_TooManyPointsShort : string.Empty) + + } + +
+ } + + @if (ShowComparison) + { +
+ + @foreach (var comparison in ComparisonOptions) + { + @comparison.Display() + } + +
+ } + + @if (Metrics is { Count: > 1 }) + { +
+ + @foreach (var metric in Metrics) + { + @metric.Display() + } + +
+ } + + @ChildContent + + @if (CanReset) + { + @S.Toolbar_Reset + } + + @if (ExportHref is not null) + { + @S.Toolbar_Export + } +
+ +@foreach (var notice in Query.Notices.Where(n => !_dismissed.Contains(n))) +{ + + @notice.Kind.Display() + +} + +@if (Plan is { Refused: true } refused) +{ + +
+ @Loc.F(S.Toolbar_TooManyPoints, refused.Requested == BucketSize.Auto ? refused.Size.Display() : refused.Requested.Display(), refused.PointCount, AnalysisLimits.MaxPoints) + @if (refused.Suggested is { } suggested) + { + + @Loc.F(S.Toolbar_UseBucket, suggested.Display()) + + } +
+
+} + +@code { + /// The page's analysis state, parsed from its address. + [Parameter, EditorRequired] + public AnalysisQuery Query { get; set; } = null!; + + /// The period resolved to — its effective dates and zone are shown; null while resolving. + [Parameter] + public ResolvedPeriod? Period { get; set; } + + /// The page defaults; with them the toolbar offers to reset period, bucket and comparison. + [Parameter] + public AnalysisDefaults? Defaults { get; set; } + + /// Raised with the new state; the page navigates with replace: true (). + [Parameter] + public EventCallback QueryChanged { get; set; } + + /// The presets offered, in order; all eight by default. + [Parameter] + public IReadOnlyList? Presets { get; set; } + + [Parameter] + public bool ShowBucket { get; set; } = true; + + [Parameter] + public bool ShowComparison { get; set; } = true; + + /// The metrics the page can show; a select appears for two or more. + [Parameter] + public IReadOnlyList? Metrics { get; set; } + + /// What a query without metric shows; choosing it writes no metric key. + [Parameter] + public AnalysisMetric? NaturalMetric { get; set; } + + /// The plan the reader used: names the automatic bucket size and reports a refused bucket (D-05). + [Parameter] + public BucketPlan? Plan { get; set; } + + /// Years offered for compare=year:YYYY on a year-aligned period; the five before the period's year by default. + [Parameter] + public IReadOnlyList? ComparisonYears { get; set; } + + /// The CSV export of what the page shows (); no button when null. + [Parameter] + public string? ExportHref { get; set; } + + /// + /// The toolbar of a record tab (D-50): the dates shown are the whole named range the records are listed for — rows + /// dated after now included and marked — rather than the analysis's "up to today" (). + /// + [Parameter] + public bool ShowsRecords { get; set; } + + /// Further controls (a scope selector) in the same wrapping row. + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string? Class { get; set; } + + private static readonly IReadOnlyList AllPresets = Enum.GetValues(); + private static readonly IReadOnlyList BucketOptions = Enum.GetValues(); + private static readonly DateTime MinDate = PeriodResolver.MinSupportedDate.ToDateTime(TimeOnly.MinValue); + private static readonly DateTime MaxDate = PeriodResolver.MaxSupportedDate.ToDateTime(TimeOnly.MinValue); + + private readonly HashSet _dismissed = []; + private AnalysisQuery? _synced; + private bool _customMode; + private DateTime? _from; + private DateTime? _to; + + private IReadOnlyList PresetOptions + { + get + { + var presets = Presets is { Count: > 0 } chosen ? chosen : AllPresets; + return presets.Contains(PeriodPreset.Custom) ? presets : [.. presets, PeriodPreset.Custom]; + } + } + + private PeriodPreset SelectedPreset => _customMode ? PeriodPreset.Custom : Query.Period; + + private AnalysisMetric SelectedMetric => Query.Metric ?? NaturalMetric ?? Metrics![0]; + + private string? RangeText => Period is null ? null : ShowsRecords ? MeterDetails.MeterRecordRange.Text(Period) : Format.PeriodRange(Period); + + private string NowText => Period is null + ? string.Empty + : TimeZoneInfo.ConvertTime(Period.Now, Period.Zone).ToString("t", System.Globalization.CultureInfo.CurrentCulture); + + private DateOnly? FromDay => _from is { } from ? DateOnly.FromDateTime(from) : null; + + private DateOnly? ToDay => _to is { } to ? DateOnly.FromDateTime(to) : null; + + private bool RangeIsBackwards => FromDay is { } from && ToDay is { } to && from > to; + + private bool CanApply => + PeriodResolver.IsValidCustomRange(FromDay, ToDay) + && !(Query.IsCustom && Query.From == FromDay && Query.To == ToDay); + + private IEnumerable ComparisonOptions + { + get + { + List options = + [ + ComparisonRequest.None, + new ComparisonRequest(ComparisonKind.PreviousPeriod), + new ComparisonRequest(ComparisonKind.PreviousYear), + ]; + + if (Period is { } period && IsYearAligned(period)) + { + var years = ComparisonYears ?? [.. Enumerable.Range(1, 5).Select(back => period.FirstDay.Year - back)]; + options.AddRange(years + .Where(y => y != period.FirstDay.Year && y >= PeriodResolver.MinSupportedDate.Year && y <= PeriodResolver.MaxSupportedDate.Year) + .Distinct() + .OrderDescending() + .Select(y => new ComparisonRequest(ComparisonKind.Year, y))); + } + + // The current choice is always listed, even when the period no longer suits it. + if (!options.Contains(Query.Comparison)) + { + options.Add(Query.Comparison); + } + + return options; + } + } + + protected override void OnParametersSet() + { + // Compared by value: a page that re-parses the same address must not close the custom fields just opened. + if (Query == _synced) + { + return; + } + + // A new state from the page: the custom fields show what it holds (or close, for a preset). + _synced = Query; + _customMode = Query.IsCustom; + _from = Query.From?.ToDateTime(TimeOnly.MinValue); + _to = Query.To?.ToDateTime(TimeOnly.MinValue); + _dismissed.RemoveWhere(n => !Query.Notices.Contains(n)); + } + + /// True when period, bucket or comparison differ from the page defaults. + private bool CanReset => Defaults is { } d + && (Query.IsCustom || Query.Period != d.Period || Query.Bucket != d.Bucket || !Query.Comparison.Equals(d.Comparison)); + + private async Task ResetAsync() + { + if (Defaults is { } d) + { + _customMode = false; + await QueryChanged.InvokeAsync(Query.WithPeriod(d.Period).WithBucket(d.Bucket).WithComparison(d.Comparison)); + } + } + + private async Task OnPresetChanged(PeriodPreset preset) + { + if (preset == PeriodPreset.Custom) + { + // Custom waits for Apply; it starts from the dates shown now. + _customMode = true; + if (Period is { } period && !period.HasNoHistory()) + { + _from ??= period.FirstDay.ToDateTime(TimeOnly.MinValue); + _to ??= (period.HasNotStarted() ? period.LastDay : period.EffectiveLastDay()).ToDateTime(TimeOnly.MinValue); + } + + return; + } + + _customMode = false; + if (preset != Query.Period || Query.IsCustom) + { + await QueryChanged.InvokeAsync(Query.WithPeriod(preset)); + } + } + + private async Task ApplyCustomAsync() + { + if (CanApply) + { + await QueryChanged.InvokeAsync(Query.WithCustomRange(FromDay!.Value, ToDay!.Value)); + } + } + + private async Task OnBucketChanged(BucketSize size) + { + if (size != Query.Bucket) + { + await QueryChanged.InvokeAsync(Query.WithBucket(size)); + } + } + + private async Task OnComparisonChanged(ComparisonRequest comparison) + { + if (!comparison.Equals(Query.Comparison)) + { + await QueryChanged.InvokeAsync(Query.WithComparison(comparison)); + } + } + + private async Task OnMetricChanged(AnalysisMetric metric) + { + AnalysisMetric? chosen = metric == NaturalMetric ? null : metric; + if (chosen != Query.Metric) + { + await QueryChanged.InvokeAsync(Query.WithMetric(chosen)); + } + } + + /// "Automatic (Monthly)" while the reader chose monthly buckets; a size's own word otherwise. + private string BucketText(BucketSize size) => + size == BucketSize.Auto && Plan is { Requested: BucketSize.Auto, Refused: false } plan + ? Loc.F(S.Toolbar_AutoBucket, plan.Size.Display()) + : size.Display(); + + /// True when an explicit size would exceed the point limit over the period (D-05): offered, but disabled. + private bool ExceedsLimit(BucketSize size) => + size != BucketSize.Auto && Period is { } period && BucketPlanner.CountBuckets(period, size) > AnalysisLimits.MaxPoints; + + /// A named-year comparison needs exactly one calendar year (a year to date counts). + private static bool IsYearAligned(ResolvedPeriod period) + { + var first = period.FirstDay; + var last = period.NominalLastDay(); + return first is { Month: 1, Day: 1 } && last is { Month: 12, Day: 31 } && first.Year == last.Year; + } +} diff --git a/src/App/Components/Shared/Analysis/ProjectionNote.razor b/src/App/Components/Shared/Analysis/ProjectionNote.razor new file mode 100644 index 0000000..68bd632 --- /dev/null +++ b/src/App/Components/Shared/Analysis/ProjectionNote.razor @@ -0,0 +1,24 @@ +@* A projection, kept apart from actuals and labelled with its method (D-09): "Projection (straight-line from N days)". + The page decides whether one may be shown (coverage, age, resolution); a change chip never compares it. *@ + +
+
+ +@code { + /// The covered days the straight line is drawn from. + [Parameter, EditorRequired] + public int Days { get; set; } + + /// The projected value, formatted with its unit or currency. + [Parameter] + public string? ValueText { get; set; } + + [Parameter] + public string? Class { get; set; } + + private string Text => string.IsNullOrWhiteSpace(ValueText) + ? Loc.F(S.Projection_Label, Days) + : Loc.F(S.Projection_LabelWithValue, Days, ValueText); +} diff --git a/src/App/Components/Shared/Analysis/RefreshIndicator.razor b/src/App/Components/Shared/Analysis/RefreshIndicator.razor new file mode 100644 index 0000000..382376a --- /dev/null +++ b/src/App/Components/Shared/Analysis/RefreshIndicator.razor @@ -0,0 +1,25 @@ +@* Keeps the previous content visible while a newer one loads (brief §8, "stale but visible"): the content dims and a + thin progress bar runs above it, and the region says it is busy. No transition for a reader who asked for reduced + motion (app.css). *@ + +
+
+ @if (Refreshing) + { + + } +
+
@ChildContent
+
+ +@code { + /// A newer load is running; the content shown is from before. + [Parameter] + public bool Refreshing { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string? Class { get; set; } +} diff --git a/src/App/Components/Shared/Analysis/SeriesContributions.razor b/src/App/Components/Shared/Analysis/SeriesContributions.razor new file mode 100644 index 0000000..e636165 --- /dev/null +++ b/src/App/Components/Shared/Analysis/SeriesContributions.razor @@ -0,0 +1,166 @@ +@* A virtual meter's sources (brief §5.1, §5.4, D-27): its formula with each m token beside the meter's name, and a + table of the sources — name (linking to the source's own analysis over the same period), factor, the source's own + value and status, what entered the formula over the days all sources cover, and its share of the result. Nested + virtual sources are indented under the one that uses them. *@ + +
+ @if (Series.Virtual is { } info) + { +
+ @S.Contributions_Formula + @if (FormulaText.Split(info.Expression) is { Count: > 0 } segments) + { + + @foreach (var segment in segments) + { + @if (segment.MeterId is { } id) + { + + @NameOf(id) + @segment.Text + + } + else + { + @segment.Text + } + } + + } + else + { + @Format.Unknown + } + @if (info.Status != VirtualMeterStatus.Valid) + { + @info.Status.Display() + } +
+ @Loc.F(S.Contributions_CostRule, info.CostRule.Display()) + } + + @if (_rows.Count == 0) + { + @S.Contributions_None + } + else + { +
+ + + + @S.Contributions_Source + @S.Contributions_Factor + @S.Contributions_Value + @S.Common_Status + @S.Contributions_Used + @S.Contributions_Share + + + + @foreach (var (contribution, depth) in _rows) + { + var status = FigureText.Of(contribution.Total, NameOf); + var unit = UnitOf(contribution.MeterId); + + + @NameOf(contribution.MeterId) + @if (contribution.IsVirtual) + { + · @S.Contributions_Calculated + } + + @FactorText(contribution.Coefficient) + @Format.Quantity(status.IsKnown ? contribution.Total.Value : null, unit) + + @status.Summary + @if (status.Detail is not null) + { +
@status.Detail
+ } + + @Format.Quantity(contribution.UsedTotal, unit) + @ShareText(contribution) + + } + +
+
+ @S.Contributions_UsedNote + } +
+ +@code { + /// The virtual meter's series (, ). + [Parameter, EditorRequired] + public AnalysisSeries Series { get; set; } = null!; + + /// The page's analysis state: source links carry its period. + [Parameter] + public AnalysisQuery? Query { get; set; } + + /// A source's unit where it differs from the result's (a ratio's operands); the result's unit otherwise. + [Parameter] + public Func? UnitOfSource { get; set; } + + /// Names for meters the contributions do not carry (a formula token of an unreadable source). + [Parameter] + public Func? MeterName { get; set; } + + [Parameter] + public string? Class { get; set; } + + private readonly Dictionary _names = []; + private List<(SeriesContribution Contribution, int Depth)> _rows = []; + + protected override void OnParametersSet() + { + _names.Clear(); + _rows = []; + Flatten(Series.Contributions, 0); + } + + private void Flatten(IReadOnlyList contributions, int depth) + { + foreach (var contribution in contributions) + { + _rows.Add((contribution, depth)); + if (!string.IsNullOrWhiteSpace(contribution.Name)) + { + _names.TryAdd(contribution.MeterId, contribution.Name); + } + + Flatten(contribution.Nested, depth + 1); + } + } + + private string NameOf(int meterId) => + _names.TryGetValue(meterId, out var name) ? name + : MeterName?.Invoke(meterId) is { Length: > 0 } other ? other + : Loc.F(S.Attention_MeterFallback, meterId); + + /// The source's unit: given, or the result's unless the result is an indicator (whose operands differ). + private string? UnitOf(int meterId) => + UnitOfSource?.Invoke(meterId) ?? (Series.Kind == QuantityKind.Indicator ? null : Series.Unit); + + /// "+1", "−1", "+0,5"; "not linear" when the formula has no factor for the source. + private static string FactorText(double? coefficient) + { + if (coefficient is not { } factor || !double.IsFinite(factor)) + { + return S.Contributions_NotLinear; + } + + var size = Math.Abs(factor).ToString("0.####", System.Globalization.CultureInfo.CurrentCulture); + return (factor < 0 ? "−" : "+") + size; + } + + /// The indent of a nested source (CSS: invariant numbers, whatever the reader's language). + private static string IndentOf(int depth) => + string.Create(System.Globalization.CultureInfo.InvariantCulture, $"padding-left:{0.75 + (depth * 1.25)}rem"); + + private string ShareText(SeriesContribution contribution) => + contribution.Coefficient is { } factor && contribution.UsedTotal is { } used && double.IsFinite(factor * used) + ? Format.Quantity(factor * used, Series.Unit) + : Format.Unknown; +} diff --git a/src/App/Components/Shared/Analysis/ValueStatus.razor b/src/App/Components/Shared/Analysis/ValueStatus.razor new file mode 100644 index 0000000..9278af8 --- /dev/null +++ b/src/App/Components/Shared/Analysis/ValueStatus.razor @@ -0,0 +1,62 @@ +@* A value's status in words (D-14, brief §4.3): its availability, the reason (with the meter a derived value misses) + and where it comes from. A chip, or inline text for tables and tooltips. A plain complete value shows nothing unless + asked to (ShowWhenComplete). *@ + +@if (_status is not null && (ShowWhenComplete || _status.IsQualified)) +{ + @if (Inline) + { + + @_status.Summary + @if (ShowDetail && _status.Detail is not null) + { + — @_status.Detail + } + + } + else + { + + @_status.Summary + + @if (ShowDetail && _status.Detail is not null) + { + @_status.Detail + } + } +} + +@code { + /// A quantity value. + [Parameter] + public BucketValue? Value { get; set; } + + /// A cost figure instead. + [Parameter] + public CostAmount? Cost { get; set; } + + /// Names a meter id in a dependency detail ("Solar 2"); "#id" without it. + [Parameter] + public Func? MeterName { get; set; } + + /// Inline text instead of a chip. + [Parameter] + public bool Inline { get; set; } + + /// Also for a plain complete value ("Complete · Measured"). + [Parameter] + public bool ShowWhenComplete { get; set; } + + /// The reason as visible text, not only as a tooltip. + [Parameter] + public bool ShowDetail { get; set; } = true; + + [Parameter] + public string? Class { get; set; } + + private FigureStatus? _status; + + protected override void OnParametersSet() => + _status = Cost is { } cost ? FigureText.Of(cost) : Value is { } value ? FigureText.Of(value, MeterName) : null; +} diff --git a/src/App/Components/Shared/Analysis/_Imports.razor b/src/App/Components/Shared/Analysis/_Imports.razor new file mode 100644 index 0000000..55f0d6c --- /dev/null +++ b/src/App/Components/Shared/Analysis/_Imports.razor @@ -0,0 +1,5 @@ +@* The analysis components speak the analysis layer's types directly (periods, buckets, values, results). *@ +@using MeterVault.App.Theme +@using MeterVault.Core.Analysis +@using MeterVault.Core.Analysis.Costing +@using MeterVault.Infrastructure.Analysis diff --git a/src/App/Components/Shared/CategoryDonut.razor b/src/App/Components/Shared/CategoryDonut.razor deleted file mode 100644 index 26a1ee4..0000000 --- a/src/App/Components/Shared/CategoryDonut.razor +++ /dev/null @@ -1,24 +0,0 @@ -@using ApexCharts - -@if (Slices is { Count: > 0 }) -{ - - - -} - -@code { - [Parameter, EditorRequired] - public IReadOnlyList Slices { get; set; } = []; - - private readonly ApexChartOptions _options = new() - { - Legend = new Legend { Position = LegendPosition.Bottom }, - Theme = new Theme { Mode = Mode.Dark }, - }; -} diff --git a/src/App/Components/Shared/DeltaChip.razor b/src/App/Components/Shared/DeltaChip.razor deleted file mode 100644 index 52b3560..0000000 --- a/src/App/Components/Shared/DeltaChip.razor +++ /dev/null @@ -1,19 +0,0 @@ -@if (Kpi.Previous != 0 || Kpi.Current != 0) -{ - - @Format.DirectionIcon(Kpi.Direction) @Format.Euro(Math.Abs(Kpi.Delta)) (@Format.Percent(Kpi.DeltaPercent)) - -} - -@code { - [Parameter, EditorRequired] - public CostKpi Kpi { get; set; } = new(0, 0); - - // For cost, up (more expensive) is bad → red; down is good → green. - private Color ChipColor => Kpi.Direction switch - { - > 0 => Color.Error, - < 0 => Color.Success, - _ => Color.Default, - }; -} diff --git a/src/App/Components/Shared/MeterEditing/VirtualCalculationEditor.razor b/src/App/Components/Shared/MeterEditing/VirtualCalculationEditor.razor new file mode 100644 index 0000000..cc953d4 --- /dev/null +++ b/src/App/Components/Shared/MeterEditing/VirtualCalculationEditor.razor @@ -0,0 +1,329 @@ +@using MeterVault.App.MeterEditing +@using MeterVault.Core.Analysis +@using MeterVault.Core.Analysis.Virtual +@using MeterVault.Infrastructure.Analysis + +@* A virtual meter's calculation (brief §5.1, D-25, D-26, D-31): Sum, Difference or a formula, its sources picked by name + with their unit, kind and service dates, what the result measures, how it is costed, and every validation finding in + words. The model holds the rules; this only renders them and reports each change. *@ +
+ @S.MeterEditor_CalculationTitle + @S.Meters_VirtualHelp + + @if (Model.IsLegacyProposal && Model.Validation?.Formula is { } proposal) + { + + @Loc.F(S.MeterEditor_LegacyProposal, Readable(proposal.Text)) + + } + else if (Model.Status == VirtualMeterStatus.NeedsConfiguration) + { + + @S.MeterEditor_NeedsConfiguration + @if (Model.Legacy is { NeedsConfiguration: true } legacy) + { + @MeterEditorText.Legacy(legacy, Model.Name) + } + + } + else if (Model.Status == VirtualMeterStatus.Malformed) + { + @S.MeterEditor_Malformed + } + + + + + + + @ModeHelp + + @switch (Model.Draft.Mode) + { + case CalculationMode.Sum: + + @foreach (var option in CalculationSources.ForSum(Model.Options, Model.Draft.SumSources)) + { + @SourceItem(option) + } + + break; + + case CalculationMode.Difference: + + @foreach (var option in CalculationSources.ForMinuend(Model.Options, Model.Draft.Minuend)) + { + @SourceItem(option) + } + + + @foreach (var option in CalculationSources.ForSubtrahends(Model.Options, Model.Draft.Minuend, Model.Draft.Subtrahends)) + { + @SourceItem(option) + } + + break; + + default: + + @if (FormulaText.Split(Model.Draft.Expression) is { Count: > 0 } segments && segments.Any(s => s.IsMeter)) + { +
+ @S.MeterEditor_FormulaReads + + @foreach (var segment in segments) + { + @if (segment.MeterId is { } id) + { + @Model.Name(id) @segment.Text + } + else + { + @segment.Text + } + } + +
+ } + + @foreach (var option in Model.Options.Where(o => o.IsEvaluable)) + { + + @CalculationDraft.Token(option.MeterId)@SourceItem(option) + + } + + break; + } + +
+ + @KindText(null) + @foreach (var kind in DeclaredVirtualResultKinds) + { + @kind.Display() + } + + +
+ + + @CostRuleText(null) + @foreach (var rule in Model.OfferedCostRules) + { + @rule.Display() + } + + @if (UnavailableRules() is { Count: > 0 } unavailable) + { + + @foreach (var line in unavailable) + { + @line + } + + } + @if (Model.CostRuleReset) + { + @S.MeterEditor_CostRuleReset + } + + @foreach (var problem in Model.Problems) + { + @MeterEditorText.Problem(problem, Model.Name) + } + @if (!Model.Draft.IsIncomplete && Model.Validation is { IsValid: true } valid && valid.Kind == QuantityKind.Indicator) + { + @S.MeterEditor_IndicatorNote + } + @if (Model.Draft.Mode != CalculationMode.Sum && CalculationSources.MixesKinds(Model.Options, Model.Validation?.Formula?.MeterIds ?? [])) + { + @S.MeterEditor_MixedKindsHint + } + + @if (Model.OffersLinkSync) + { +
+ + + @if (Model.LinksToAdd.Count > 0) + { + @Loc.F(S.MeterEditor_SyncLinksAdd, NamesOf(Model.LinksToAdd)) + } + @if (Model.LinksToRemove.Count > 0) + { + @Loc.F(S.MeterEditor_SyncLinksRemove, NamesOf(Model.LinksToRemove)) + } + @S.MeterEditor_SyncLinksHelp + +
+ } +
+ +@code { + private static readonly QuantityKind[] DeclaredVirtualResultKinds = + [.. MeterVault.Core.Analysis.Quantities.DeclaredVirtualResult.AllowedKinds]; + + /// The section's state and rules; the editor owns it and reads it on save. + [Parameter, EditorRequired] + public VirtualCalculationModel Model { get; set; } = null!; + + /// Raised after every change, once the model is validated again. + [Parameter] + public EventCallback Changed { get; set; } + + /// An energy type's name, for sources outside the meter's own type. + [Parameter] + public Func? EnergyTypeName { get; set; } + + /// The meter's energy type (its meters need no type name). + [Parameter] + public int EnergyTypeId { get; set; } + + private string ModeHelp => Model.Draft.Mode switch + { + CalculationMode.Sum => S.MeterEditor_ModeSumHelp, + CalculationMode.Difference => S.MeterEditor_ModeDifferenceHelp, + _ => S.MeterEditor_ModeAdvancedHelp, + }; + + private string? UnitHelp => + Model.EffectiveKind == QuantityKind.Indicator + ? S.MeterEditor_ResultUnitIndicator + : Model.InferredUnit is { } unit ? Loc.F(S.MeterEditor_ResultUnitAuto, unit) : S.MeterEditor_ResultUnitHelp; + + private string CostRuleHelp => (Model.Draft.CostRule ?? Model.DefaultCostRule) switch + { + VirtualCostRule.SourceCosts => S.MeterEditor_CostRuleSourceCostsHelp, + VirtualCostRule.OwnQuantity => S.MeterEditor_CostRuleOwnQuantityHelp, + _ => S.MeterEditor_CostRuleNoneHelp, + }; + + private string KindText(QuantityKind? kind) => kind is { } k + ? k.Display() + : Model.InferredKind is { } inferred + ? Loc.F(S.MeterEditor_Automatic, inferred.Display()) + : S.MeterEditor_ResultKindChoose; + + private string CostRuleText(VirtualCostRule? rule) => rule is { } r + ? r.Display() + : Loc.F(S.MeterEditor_Automatic, Model.DefaultCostRule.Display()); + + /// Why the rules the list leaves out are left out, one sentence each. + private List UnavailableRules() + { + var lines = new List(); + foreach (var rule in new[] { VirtualCostRule.SourceCosts, VirtualCostRule.OwnQuantity }) + { + if (Model.WhyNot(rule) is not { } why || why.Reason == CostRuleBlock.NoFormula) + { + continue; + } + + var reason = why.Reason switch + { + CostRuleBlock.Indicator => S.MeterEditor_CostBlockIndicator, + CostRuleBlock.NotPureSum => S.MeterEditor_CostBlockNotPureSum, + CostRuleBlock.NestedNotPureSum => Loc.F(S.MeterEditor_CostBlockNested, why.MeterId is { } id ? Model.Name(id) : S.MeterEditor_AnotherMeter), + CostRuleBlock.Generation => S.MeterEditor_CostBlockGeneration, + _ => S.MeterEditor_CostBlockNotLinear, + }; + lines.Add(Loc.F(S.MeterEditor_CostRuleUnavailable, rule.Display(), reason)); + } + + return lines; + } + + private RenderFragment SourceItem(SourceOption option) => __builder => + { + + @option.Name + @SourceDetail(option) + + }; + + /// "kWh · Generation · from 1 Mar 2021 · Strom": what the source measures, when it counts, and — outside the meter's own type — its type. + private string SourceDetail(SourceOption option) + { + var parts = new List(5) { option.Unit, option.Kind.Display() }; + if (option.IsVirtual) + { + parts.Add(S.MeterEditor_SourceCalculated); + } + + switch (option.InstalledAt, option.RetiredAt) + { + case ({ } from, { } to): + parts.Add(Format.DateRange(from, to)); + break; + case ({ } from, null): + parts.Add(Loc.F(S.MeterEditor_SourceFrom, Format.Date(from))); + break; + case (null, { } to): + parts.Add(Loc.F(S.MeterEditor_SourceUntil, Format.Date(to))); + break; + } + + if (option.EnergyTypeId != EnergyTypeId && EnergyTypeName?.Invoke(option.EnergyTypeId) is { } type) + { + parts.Add(type); + } + + return string.Join(" · ", parts); + } + + private string NamesOf(IEnumerable ids) => string.Join(", ", ids.Select(Model.Name)); + + private string NamesOf(IReadOnlyList ids) => + string.Join(", ", ids.Select(text => int.TryParse(text, out var id) ? Model.Name(id) : text)); + + /// A formula with names instead of tokens, for sentences: "Solar 1 + Solar 2". + private string Readable(string expression) => + string.Concat(FormulaText.Split(expression).Select(s => s.MeterId is { } id ? Model.Name(id) : s.Text)); + + private Task SwitchModeAsync(CalculationMode mode) => Update(() => Model.Draft.SwitchTo(mode)); + + private Task SetSumAsync(IReadOnlyCollection? ids) => Update(() => Model.Draft.SetSumSources(ids ?? [])); + + private Task SetMinuendAsync(int? id) => Update(() => Model.Draft.SetMinuend(id)); + + private Task SetSubtrahendsAsync(IReadOnlyCollection? ids) => Update(() => Model.Draft.SetSubtrahends(ids ?? [])); + + private Task SetExpressionAsync(string? text) => Update(() => Model.Draft.Expression = text ?? ""); + + private Task InsertAsync(int? id) => id is { } meterId ? Update(() => Model.Draft.InsertReference(meterId)) : Task.CompletedTask; + + private Task SetKindAsync(QuantityKind? kind) => Update(() => Model.Draft.ResultKind = kind); + + private Task SetUnitAsync(string? unit) => Update(() => Model.Draft.ResultUnit = unit); + + private Task SetCostRuleAsync(VirtualCostRule? rule) => Update(() => Model.Draft.CostRule = rule); + + private Task SetSyncAsync(bool sync) + { + Model.SyncLinks = sync; + return Changed.InvokeAsync(); + } + + private Task Update(Action change) + { + change(); + Model.Refresh(); + return Changed.InvokeAsync(); + } +} diff --git a/src/App/Components/Shared/MeterEditing/VirtualCalculationPreview.razor b/src/App/Components/Shared/MeterEditing/VirtualCalculationPreview.razor new file mode 100644 index 0000000..aaf82dd --- /dev/null +++ b/src/App/Components/Shared/MeterEditing/VirtualCalculationPreview.razor @@ -0,0 +1,261 @@ +@using MeterVault.App.MeterEditing +@using MeterVault.Core.Analysis +@using MeterVault.Core.Analysis.Virtual +@using MeterVault.Infrastructure.Analysis +@using MeterVault.App.Components.Shared.Analysis +@inject InstanceClock Clock +@inject ILogger Logger +@implements IDisposable + +@* The live preview of an unsaved calculation (brief §5.1, D-31): what it would show per month over the selected + period — the page's period when the editor was opened from one, any preset, all available history (the sources' own + dates, however old) or custom dates, through the shared period toolbar — and what each source contributed, read + through the shared reader with the draft laid over the stored meters, so a missing source month reads as incomplete, + never as a confident number. *@ +
+ @S.MeterEditor_Preview + + + @if (Definition is null) + { + @S.MeterEditor_PreviewNeedsValid + } + else + { + + @if (preview.Series is not { } series) + { + @S.MeterEditor_PreviewNeedsValid + } + else + { + var total = FigureText.Of(series.Total, Model.Name); + + @Loc.F(S.MeterEditor_PreviewTotal, Format.Quantity(total.IsKnown ? series.Total.Value : null, series.Unit), series.Kind.Display()) + @if (!total.IsComplete) + { + · @total.Full + } + + @if (!series.IsAdditive) + { + @S.MeterEditor_PreviewNotAdditive + } + @if (preview.Chart.Count > 0 && series.Values.Any(v => v.Value is not null)) + { + + } +
+ + + + @S.MeterEditor_PreviewMonth + @S.MeterEditor_PreviewResult + @foreach (var source in preview.Sources) + { + @Model.Name(source.MeterId) + } + + + + @for (var i = 0; i < preview.Buckets.Count; i++) + { + var index = i; + + @Format.BucketLabel(preview.Buckets[index], preview.SpansYears) + @Cell(series.Values[index], series.Unit, strong: true) + @foreach (var source in preview.Sources) + { + @Cell(index < source.Values.Count ? source.Values[index] : null, Model.Option(source.MeterId)?.Unit ?? series.Unit, strong: false) + } + + } + + @S.MeterEditor_PreviewTotalRow + @Cell(series.Total, series.Unit, strong: true) + @foreach (var source in preview.Sources) + { + @Cell(source.Total, Model.Option(source.MeterId)?.Unit ?? series.Unit, strong: false) + } + + + +
+ @if (preview.Warnings.Count > 0) + { + @S.MeterEditor_PreviewIncomplete +
    + @foreach (var warning in preview.Warnings.Take(MaxWarnings)) + { +
  • @warning
  • + } + @if (preview.Warnings.Count > MaxWarnings) + { +
  • @Loc.F(S.MeterEditor_PreviewMoreWarnings, preview.Warnings.Count - MaxWarnings)
  • + } +
+ } + } +
+ } +
+ +@code { + private const int MaxWarnings = 4; + + private readonly LoadSequencer _loads = new(); + private readonly LoadState _state = new(); + private AnalysisQuery _query = VirtualPreviewPeriod.Initial(null); + private bool _queryInitialized; + private AnalysisQuery? _initialSeen; + private VirtualDefinition? _loadedDefinition; + private MeterDraft? _loadedDraft; + + /// Reads the draft through the shared reader. + [Parameter, EditorRequired] + public MeterDraftAnalysis Analysis { get; set; } = null!; + + /// The calculation section's model: the stored catalog and the meters' names. + [Parameter, EditorRequired] + public VirtualCalculationModel Model { get; set; } = null!; + + /// The meter being edited (name, type, id in the catalog). + [Parameter, EditorRequired] + public MeterDraft Draft { get; set; } = null!; + + /// The effective definition to preview; null while the calculation cannot be saved. + [Parameter] + public VirtualDefinition? Definition { get; set; } + + /// The analysis state of the page the editor was opened from; the preview opens on its period. + [Parameter] + public AnalysisQuery? InitialQuery { get; set; } + + private sealed record SourceColumn(int MeterId, IReadOnlyList Values, BucketValue Total); + + private sealed record Preview( + ResolvedPeriod Period, + IReadOnlyList Buckets, + bool SpansYears, + AnalysisSeries? Series, + IReadOnlyList Sources, + IReadOnlyList Chart, + IReadOnlyList Warnings); + + protected override async Task OnParametersSetAsync() + { + // The page's period opens the preview; a period chosen here stays until the page's own changes. + if (!_queryInitialized || !Equals(InitialQuery, _initialSeen)) + { + var initial = VirtualPreviewPeriod.Initial(InitialQuery); + var changed = _queryInitialized && initial != _query; + _query = initial; + _initialSeen = InitialQuery; + _queryInitialized = true; + if (changed && Definition is not null) + { + await ReloadAsync(); + return; + } + } + + // Only a different calculation reloads; typing a name or toggling a switch elsewhere in the dialog does not. + if (Definition is null) + { + _loadedDefinition = null; + return; + } + + if (Definition == _loadedDefinition && _loadedDraft is { } loaded && loaded.CatalogId == Draft.CatalogId + && loaded.EnergyTypeId == Draft.EnergyTypeId) + { + return; + } + + await ReloadAsync(); + } + + private async Task SetQueryAsync(AnalysisQuery query) + { + _query = query; + await ReloadAsync(); + } + + private Task ReloadAsync() + { + if (Definition is not { } definition) + { + return Task.CompletedTask; + } + + _loadedDefinition = definition; + _loadedDraft = Draft; + var draft = Draft with { Definition = definition }; + var query = _query; + var model = Model; + return _loads.RunAsync(_state, async token => + { + var overlay = MeterDraftAnalysis.Overlay(model.Catalog, draft, definition); + var period = await VirtualPreviewPeriod.ResolveAsync(Analysis, overlay, draft, query, Clock.Now, token); + + // Months, unless the range holds more than a chart can (all history of a long-lived source): then years. + var bucket = BucketPlanner.Plan(period, BucketSize.Month).Refused ? BucketSize.Year : BucketSize.Month; + var result = await Analysis.PreviewAsync(overlay, draft, period, bucket, token); + var series = result.SeriesFor(draft.CatalogId); + var buckets = result.Plan.Buckets; + if (series is null) + { + return new Preview(period, buckets, Format.SpansYears(buckets), null, [], [], []); + } + + List sources = [.. series.Contributions.Select(c => new SourceColumn(c.MeterId, c.Values, c.Total))]; + List chart = [AnalysisChartSeries.ForSeries(series, series.Name)]; + var spansYears = Format.SpansYears(buckets); + List warnings = []; + for (var i = 0; i < buckets.Count && i < series.Values.Count; i++) + { + var status = FigureText.Of(series.Values[i], model.Name); + if (!status.IsComplete) + { + warnings.Add(Format.BucketLabel(buckets[i], spansYears) + ": " + status.Full); + } + } + + return new Preview(period, buckets, spansYears, series, sources, chart, warnings); + }, Logger); + } + + private RenderFragment Cell(BucketValue? value, string unit, bool strong) => __builder => + { + if (value is null) + { + @Format.Unknown + return; + } + + var status = FigureText.Of(value, Model.Name); + if (!status.IsKnown) + { + @status.Status + return; + } + + + @if (strong) + { + @Format.Quantity(value.Value, unit) + } + else + { + @Format.Quantity(value.Value, unit) + } + @if (status.IsQualified) + { + + } + + }; + + public void Dispose() => _loads.Dispose(); +} diff --git a/src/App/Components/Shared/MeterEditor.razor b/src/App/Components/Shared/MeterEditor.razor index 1d89fc3..a1baba5 100644 --- a/src/App/Components/Shared/MeterEditor.razor +++ b/src/App/Components/Shared/MeterEditor.razor @@ -1,15 +1,25 @@ @using Microsoft.EntityFrameworkCore +@using MeterVault.App.MeterEditing +@using MeterVault.App.Components.Shared.MeterEditing @using MeterVault.Core.Normalization +@using MeterVault.Core.Analysis.Quantities +@using MeterVault.Core.Analysis.Totals +@using MeterVault.Core.Analysis.Virtual +@using MeterVault.Infrastructure.Analysis @using MeterVault.Infrastructure.Normalization @using MeterVault.Infrastructure.Persistence @inject IDbContextFactory DbFactory +@inject AnalysisReader Reader @inject INormalizationEngine Engine @inject Microsoft.Extensions.Options.IOptions Options +@inject TimeProvider Time @inject ISnackbar Snackbar +@inject NavState NavState +@inject ILogger Logger @* The one meter editor, shared by the meter list and the meter's own page: a meter's settings are edited where the user is looking at it rather than only from a pencil in a list. *@ - + @(_working.Id == 0 ? S.Meters_NewMeter : Loc.F(S.Meters_EditMeter, _working.Name)) @@ -21,82 +31,148 @@ @t.DisplayName } - + @foreach (var mode in Enum.GetValues()) { @mode.Display() } - @if (_working.Mode == MeterMode.Virtual) - { - - @S.Meters_VirtualHelp - - } - else if (_working.Mode == MeterMode.InstantRate) + @if (_working.Mode == MeterMode.InstantRate) { @S.Meters_InstantRateHelpBefore @S.Meters_InstantRateHelpPerHour @S.Meters_InstantRateHelpAfter } - - @if (_working.Mode != MeterMode.ConsumableBalance) - { - - } - @if (_working.Mode == MeterMode.ConsumableBalance) + @if (IsVirtual) { - - @S.Meters_TankSection - @S.Meters_TankHelp -
- - -
-
- - -
-
- - @foreach (var rateMode in Enum.GetValues()) - { - @rateMode.Display() - } - - @if (_working.RateMode == TankRateMode.Fixed) - { - - } -
-
- } - - - @S.Meters_RoleNone - total_load - grid_import - grid_export - - - @foreach (var m in AvailableUpstream()) + @* D-31: a virtual meter is its calculation. It has no register, baseline, device details or ingest source, so none + of those controls is offered; its sources' readings are its data. *@ + @if (_calc is { } calc) { - @m.Name + + + + + + } + else + { + @S.MeterEditor_CatalogUnavailable + } + } + else + { + + @if (_working.Mode != MeterMode.ConsumableBalance) + { + + } + @* A register, its baseline, its unit and its mode decide how the stored readings become consumption; the readings + themselves are never touched, so a change here is a recompute from them, not an edit of history. *@ + @S.MeterEditor_RecomputeHelp + + @* The install date says since when a register's first reading counted (D-10); without it that first + amount has an unknown start and its month reads as partial (D-14). It also bounds the meter's + service period (D-24). *@ + + @* A replaced meter is retired rather than deactivated: it keeps its history and its role for its own service + period (A-07, D-24), and its successor takes over from the next day. *@ + + @S.MeterEditor_LifecycleEffect + + @if (_working.Mode == MeterMode.ConsumableBalance) + { + + @S.Meters_TankSection + @S.Meters_TankHelp +
+ + +
+
+ + +
+
+ + @foreach (var rateMode in Enum.GetValues()) + { + @rateMode.Display() + } + + @if (_working.RateMode == TankRateMode.Fixed) + { + + } +
+
+ } + + @* D-21: only the roles this mode may hold (none for virtual, generation, runtime and tank meters), by their + names and meanings; saving one moves it from the meter that holds it now, and says so first. *@ + @if (MeterRoleRules.AllowedFor(_working.Mode) is { Count: > 0 } allowedRoles) + { + + @S.Meters_RoleNone + @foreach (var role in allowedRoles) + { + @role.Display() + } + + @if (RoleHolder is { } holder) + { + @Loc.F(S.Meters_RoleHeldBy, holder.Name) + } + } + + @foreach (var m in AvailableUpstream()) + { + @m.Name + } + + } + + @* D-23: whether the meter counts in its energy type's totals and bill. Automatic follows the links and roles; + "always" is refused, naming the other meter, where it would count the same energy twice. *@ + + @foreach (var value in Enum.GetValues()) + { + @value.Display() } + @if (TotalsNow is { } now) + { + @now + } + @if (TotalsCheck?.Conflict is { } conflict) + { + @MeterEditorText.Conflict(conflict, MeterName) + } + else if (StoredRefusal is { } refused) + { + + @S.MeterEditor_TotalsStoredRefused @MeterEditorText.Conflict(refused, MeterName) + + } +
+ @* A meter only has a cost once a category counts it, and nothing else on the way to the dashboard says so — so the membership is chosen where the meter is set up. *@ @if (_categories.Count == 0) { - + @S.Meters_NoCostCategories @S.Nav_CostCategories } @@ -111,13 +187,21 @@ @category.Name }
+ @if (IsVirtual) + { + @* D-39/D-42: a category prices metered meters; a calculated view in it adds nothing (A-22). *@ + @S.Meters_CostCategoriesVirtual + } + } + @if (!IsVirtual) + { + + +
+ + +
} - - -
- - -
@if (OffersSwapInstead) { @@ -147,6 +231,9 @@ @code { private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; + // A calculation, its sources and its preview table need the room; a physical meter's form reads better narrow. + private readonly DialogOptions _wideDialogOptions = new() { MaxWidth = MaxWidth.Medium, FullWidth = true }; + private bool _open; private bool _saving; private EditModel _working = new(); @@ -155,11 +242,28 @@ private List _allLinks = []; private List _categories = []; private List _typeMemberships = []; + private MeterDraftAnalysis _draftAnalysis = null!; + + /// The stored meters as analysis reads them (normalized kinds and units, definitions, totals), loaded on open. + private AnalysisCatalog? _catalog; + + /// The calculation section, while the meter is virtual; kept across mode flips so nothing typed is lost. + private VirtualCalculationModel? _calc; + + private string? _totalsSignature; + private TotalsOverrideCheck? _totalsCheck; /// Raised after a save, with the meter's id and whether it was just created. [Parameter] public EventCallback<(int MeterId, bool Created)> Saved { get; set; } + /// + /// The analysis state of the page hosting the editor (the meter page's period): the calculation preview opens on its + /// period, so a historical month or year on the page previews that month or year (brief §5.1, D-31). + /// + [Parameter] + public AnalysisQuery? PeriodQuery { get; set; } + /// /// Raised when the user, about to retire a register, chooses to record a meter swap instead. The /// host decides where that dialog lives; the editor has already closed without saving. @@ -167,16 +271,20 @@ [Parameter] public EventCallback SwapInsteadRequested { get; set; } + protected override void OnInitialized() => _draftAnalysis = new MeterDraftAnalysis(Reader, DbFactory); + public async Task OpenNewAsync() { await LoadListsAsync(); var type = _energyTypes.FirstOrDefault(); _working = new EditModel(); + _calc = null; if (type is not null) { ApplyTypeDefaults(type, previous: null); } + EnsureCalculation(); _open = true; StateHasChanged(); } @@ -194,6 +302,7 @@ var tank = await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meterId); var calibration = MeterConfigFactory.ParseCalibration(tank?.Calibration); + var totals = TotalsOverrideTokens.FromMeta(meter.Meta); _working = new EditModel { Id = meter.Id, @@ -204,7 +313,15 @@ Unit = meter.Unit, InitialBaseline = meter.InitialBaseline, OriginalBaseline = meter.InitialBaseline, - Role = MeterMeta.Role(meter.Meta) ?? "", + InstalledAt = meter.InstalledAt?.ToDateTime(TimeOnly.MinValue), + OriginalInstalledAt = meter.InstalledAt, + RetiredAt = meter.RetiredAt?.ToDateTime(TimeOnly.MinValue), + OriginalUnit = meter.Unit, + // A role the meter's mode may not hold means nothing (A-07); saving drops it. + Role = MeterRoleAssignment.AllowedToken(meter.Mode, MeterMeta.Role(meter.Meta)) ?? "", + OriginalRole = MeterMeta.Role(meter.Meta) ?? "", + Totals = totals, + OriginalTotals = totals, Location = meter.Location, SerialNumber = meter.SerialNumber, Manufacturer = meter.Manufacturer, @@ -225,12 +342,111 @@ FixedRate = tank?.FixedRate, }; _working.OriginalTank = _working.TankSignature; + _calc = null; + EnsureCalculation(); _open = true; StateHasChanged(); } + private bool IsVirtual => _working.Mode == MeterMode.Virtual; + + private DialogOptions DialogOptionsNow => IsVirtual ? _wideDialogOptions : _dialogOptions; + private string TankUnitLabel => string.IsNullOrWhiteSpace(_working.TankUnit) ? _working.Unit : _working.TankUnit.Trim(); + /// The selected role's meaning, or what a role is for while none is selected. + private string RoleHelp => MeterRoleRules.Parse(_working.Role) is { } role ? role.Meaning() : S.Meters_RoleHelp; + + /// The meter in service the selected role would move from (D-21); none for a retired meter, which takes it from nobody. + private Meter? RoleHolder => + MeterRoleRules.Parse(_working.Role) is { } role && MeterRoleRules.IsAllowed(role, _working.Mode) + ? MeterRoleAssignment.CurrentHolder(_meters, role, _working.EnergyTypeId, _working.Id, _working.RetiredAtDate is not null) + : null; + + /// What the selected totals setting does (D-23), in one line. + private string TotalsHelp => _working.Totals switch + { + TotalsOverride.Always => S.MeterEditor_TotalsAlwaysHelp, + TotalsOverride.Never => S.MeterEditor_TotalsNeverHelp, + _ => S.MeterEditor_TotalsAutoHelp, + }; + + /// Where the stored meter counts in its energy type's totals now (D-22), for an existing meter. + private string? TotalsNow => + _working.Id != 0 && _catalog?.Totals.Meters.TryGetValue(_working.Id, out var entry) == true + ? Loc.F(S.MeterEditor_TotalsNow, entry.Class.Display()) + : null; + + /// A stored "always" the totals could not apply, while the user has not changed the setting yet. + private TotalsConflict? StoredRefusal => + _working.Id != 0 && _working.Totals == _working.OriginalTotals && _catalog?.Totals.Meters.TryGetValue(_working.Id, out var entry) == true + ? entry.RefusedOverride + : null; + + /// + /// Whether the totals setting may be saved with everything else as it stands in the dialog (D-23), checked on the + /// stored meters with this draft laid over them. Recomputed only when something it reads changed. + /// + private TotalsOverrideCheck? TotalsCheck + { + get + { + if (_catalog is null || _working.Totals == TotalsOverride.Never) + { + return null; + } + + var draft = CurrentDraft(); + var definition = IsVirtual ? _calc?.EffectiveDefinition : null; + var signature = string.Join('|', + _working.Totals, draft.Mode, draft.Role, draft.EnergyTypeId, draft.InstalledAt, draft.RetiredAt, + string.Join(",", draft.Upstream?.Order() ?? Enumerable.Empty()), definition?.Expression, definition?.ResultKind, definition?.ResultUnit); + if (signature != _totalsSignature) + { + _totalsSignature = signature; + var overlay = MeterDraftAnalysis.Overlay(_catalog, draft, definition); + _totalsCheck = MeterDraftAnalysis.CheckTotals(overlay, draft, _working.Totals); + } + + return _totalsCheck; + } + } + + /// The dialog's state as analysis reads a meter (D-20 – D-26). + private MeterDraft CurrentDraft() => + new(_working.Id, _working.Name.Trim(), _working.EnergyTypeId, _working.Mode, _working.Unit.Trim()) + { + Role = MeterRoleAssignment.AllowedToken(_working.Mode, _working.Role), + InstalledAt = IsVirtual ? null : _working.InstalledAtDate, + RetiredAt = IsVirtual ? null : _working.RetiredAtDate, + Definition = IsVirtual ? _calc?.Draft.Definition : null, + Upstream = IsVirtual ? (_calc is { } calc ? UpstreamWithoutLoops(calc.LinksAfterSave) : null) : _working.Upstream, + }; + + private string MeterName(int meterId) => + _meters.FirstOrDefault(m => m.Id == meterId)?.Name ?? (meterId == _working.Id || meterId < 1 ? _working.Name : CalculationDraft.Token(meterId)); + + private string? EnergyTypeName(int typeId) => _energyTypes.FirstOrDefault(t => t.Id == typeId)?.DisplayName; + + /// A mode that may not hold the selected role drops it (A-07); a meter turning virtual gets its calculation section. + private void OnModeChanged(MeterMode mode) + { + _working.Mode = mode; + _working.Role = MeterRoleAssignment.AllowedToken(mode, _working.Role) ?? ""; + EnsureCalculation(); + } + + /// Creates the calculation section the first time the meter is virtual in this dialog. + private void EnsureCalculation() + { + if (IsVirtual && _calc is null && _catalog is not null) + { + _calc = new VirtualCalculationModel(_catalog, _working.Id > 0 ? _working.Id : MeterDraft.NewMeterId, _working.EnergyTypeId); + } + } + + private void OnCalculationChanged() => StateHasChanged(); + private bool OffersSwapInstead => _working.Id != 0 && _working.OriginalIsActive && !_working.IsActive && MeterEventRules.IsMonotonic(_working.OriginalMode) && SwapInsteadRequested.HasDelegate; @@ -243,6 +459,20 @@ _meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync(); _categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync(); _typeMemberships = await db.CostCategoryMembers.AsNoTracking().Where(m => m.EnergyTypeId != null).ToListAsync(); + try + { + _catalog = await _draftAnalysis.LoadCatalogAsync(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A physical meter can still be edited without it (only the totals check is skipped); a calculation cannot, + // and says so instead of offering sources it could not validate. + Logger.LogError(ex, "Could not load the meter catalog for the meter editor"); + _catalog = null; + } + + _totalsSignature = null; + _totalsCheck = null; } /// @@ -279,6 +509,8 @@ { ApplyTypeDefaults(type, previous); } + + EnsureCalculation(); } private void ApplyTypeDefaults(EnergyType type, EnergyType? previous) @@ -299,6 +531,13 @@ return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id)); } + /// A sum's sources as incoming links, without any that would close a loop in the flow topology. + private HashSet UpstreamWithoutLoops(IEnumerable upstream) + { + var descendants = Descendants(_working.Id); + return upstream.Where(id => id != _working.Id && !descendants.Contains(id)).ToHashSet(); + } + private HashSet Descendants(int meterId) { var result = new HashSet(); @@ -339,18 +578,44 @@ private async Task SaveAsync() { - if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0) + if (string.IsNullOrWhiteSpace(_working.Name) || _working.EnergyTypeId == 0 + || (!IsVirtual && string.IsNullOrWhiteSpace(_working.Unit))) { Snackbar.Add(S.Meters_RequiredFields, Severity.Warning); return; } + // D-26, A-08, A-15: a calculation is stored only once it validates, with a cost rule the whole calculation supports. + VirtualDefinition? definition = null; + if (IsVirtual) + { + definition = _calc?.EffectiveDefinition; + if (definition is null) + { + Snackbar.Add(S.MeterEditor_SaveBlockedCalculation, Severity.Warning); + return; + } + } + + // D-23: an override that would count the same energy twice is refused, naming the other meter. + if (TotalsCheck?.Conflict is { } conflict) + { + Snackbar.Add(MeterEditorText.Conflict(conflict, MeterName), Severity.Warning); + return; + } + if (_working.Mode == MeterMode.ConsumableBalance && _working.WantsTank && _working.TankCapacity is not > 0) { Snackbar.Add(S.Meters_TankCapacityRequired, Severity.Warning); return; } + if (!IsVirtual && _working.RetiredAtDate is { } retired && _working.InstalledAtDate is { } installed && retired < installed) + { + Snackbar.Add(S.Meters_RetiredBeforeInstalled, Severity.Warning); + return; + } + _saving = true; try { @@ -359,6 +624,9 @@ // all — a failure halfway must not leave a meter with half its settings. await using var tx = await db.Database.BeginTransactionAsync(); var created = _working.Id == 0; + var role = MeterRoleAssignment.AllowedToken(_working.Mode, _working.Role); + var unit = IsVirtual ? VirtualUnit(definition!) : _working.Unit.Trim(); + IReadOnlyList displaced = []; int meterId; if (created) { @@ -367,19 +635,28 @@ Name = _working.Name.Trim(), EnergyTypeId = _working.EnergyTypeId, Mode = _working.Mode, - Unit = _working.Unit.Trim(), - InitialBaseline = _working.Mode == MeterMode.ConsumableBalance ? 0 : _working.InitialBaseline, - Meta = MeterMeta.SetRole("{}", _working.Role), - Location = Trim(_working.Location), - SerialNumber = Trim(_working.SerialNumber), - Manufacturer = Trim(_working.Manufacturer), - Model = Trim(_working.Model), + Unit = unit, + InitialBaseline = _working.Mode is MeterMode.ConsumableBalance or MeterMode.Virtual ? 0 : _working.InitialBaseline, + InstalledAt = IsVirtual ? null : _working.InstalledAtDate, + RetiredAt = IsVirtual ? null : _working.RetiredAtDate, + Meta = ComposeMeta("{}", role, definition), + Location = IsVirtual ? null : Trim(_working.Location), + SerialNumber = IsVirtual ? null : Trim(_working.SerialNumber), + Manufacturer = IsVirtual ? null : Trim(_working.Manufacturer), + Model = IsVirtual ? null : Trim(_working.Model), IsActive = _working.IsActive, }; db.Meters.Add(meter); await db.SaveChangesAsync(); meterId = meter.Id; await SaveTankAsync(db, meterId); + + // Nothing to normalize yet, but the recompute records what the meter's analysis is built with + // (its rollup state), so a new meter reads as "no data yet" rather than "being prepared". + var normalization = new NormalizationService(db, Engine, Options, Time); + await normalization.RecomputeMeterAsync(meterId, null); + await db.SaveChangesAsync(); + displaced = await MeterRoleAssignment.ApplyAsync(db, meterId, normalization); } else { @@ -387,34 +664,57 @@ existing.Name = _working.Name.Trim(); existing.EnergyTypeId = _working.EnergyTypeId; existing.Mode = _working.Mode; - existing.Unit = _working.Unit.Trim(); - existing.InitialBaseline = _working.InitialBaseline; - existing.Meta = MeterMeta.SetRole(existing.Meta, _working.Role); - existing.Location = Trim(_working.Location); - existing.SerialNumber = Trim(_working.SerialNumber); - existing.Manufacturer = Trim(_working.Manufacturer); - existing.Model = Trim(_working.Model); + existing.Unit = unit; + // A virtual meter has no register: its baseline, dates and device details are kept as they were, unused. + if (!IsVirtual) + { + existing.InitialBaseline = _working.InitialBaseline; + existing.InstalledAt = _working.InstalledAtDate; + existing.RetiredAt = _working.RetiredAtDate; + existing.Location = Trim(_working.Location); + existing.SerialNumber = Trim(_working.SerialNumber); + existing.Manufacturer = Trim(_working.Manufacturer); + existing.Model = Trim(_working.Model); + } + + existing.Meta = ComposeMeta(existing.Meta, role, definition); existing.IsActive = _working.IsActive; existing.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); await SaveTankAsync(db, existing.Id); - if (_working.RecomputeNeeded) + // A virtual meter is recomputed on every save: it stores no rows, but its rollup state records the kind and + // unit its definition declares (D-16, D-20), and a meter that just became virtual loses its old rows. + var normalization = new NormalizationService(db, Engine, Options, Time); + if (_working.RecomputeNeeded || IsVirtual) { - var normalization = new NormalizationService(db, Engine, Options); await normalization.RecomputeMeterAsync(existing.Id, null); await db.SaveChangesAsync(); } + // D-21: a role is unique per energy type among meters in service; the meter that held it gives it up. + displaced = await MeterRoleAssignment.ApplyAsync(db, existing.Id, normalization); meterId = existing.Id; } - await SyncUpstreamAsync(db, meterId, _working.Upstream); + // Links are topology: a sum may bring its incoming links in line with its sources when asked to, and the links + // never change a saved calculation (brief §5.1). + var upstream = IsVirtual + ? (_calc is { } calc ? UpstreamWithoutLoops(calc.LinksAfterSave) : _working.Upstream) + : _working.Upstream; + await SyncUpstreamAsync(db, meterId, upstream); await SyncCategoriesAsync(db, meterId, _working.Categories); await tx.CommitAsync(); _open = false; Snackbar.Add(S.Common_Saved, Severity.Success); + if (displaced.Count > 0) + { + Snackbar.Add(Loc.F(S.Meters_RoleMovedFrom, string.Join(", ", displaced.Select(m => m.Name))), Severity.Info); + } + + // The menu's specialized views depend on which meters exist (a generation counter, a tank): let it re-check. + NavState.NotifyMetersChanged(); await Saved.InvokeAsync((meterId, created)); } catch (DbUpdateException) @@ -437,6 +737,31 @@ } } + /// + /// The meter's Meta as saved: its role (dropped where the mode may not hold one, A-07), its totals override (D-23), + /// and — for a virtual meter — its effective definition (A-08); a meter that stops being virtual loses its + /// definition keys. Every other key is kept. + /// + private string ComposeMeta(string? existing, string? role, VirtualDefinition? definition) + { + var meta = MeterMeta.SetRole(existing ?? "{}", role); + meta = MeterDraftAnalysis.WithTotalsOverride(meta, _working.Totals); + return definition is not null ? VirtualDefinitionJson.Write(meta, definition) : VirtualDefinitionJson.Remove(meta); + } + + /// A virtual meter's unit column: its result unit, or — for a result without one — what it had, or its type's. + private string VirtualUnit(VirtualDefinition definition) + { + if (!string.IsNullOrWhiteSpace(definition.ResultUnit)) + { + return definition.ResultUnit.Trim(); + } + + return !string.IsNullOrWhiteSpace(_working.Unit) + ? _working.Unit.Trim() + : _energyTypes.FirstOrDefault(t => t.Id == _working.EnergyTypeId)?.BaseUnit ?? ""; + } + /// /// Creates or updates the tank for a consumable meter. A tank is left alone when the meter moves /// to another mode — its calibration is configuration somebody measured, not something to lose @@ -523,7 +848,14 @@ public string Unit { get; set; } = ""; public double InitialBaseline { get; set; } public double OriginalBaseline { get; set; } + public DateTime? InstalledAt { get; set; } + public DateOnly? OriginalInstalledAt { get; set; } + public DateTime? RetiredAt { get; set; } + public string OriginalUnit { get; set; } = ""; public string Role { get; set; } = ""; + public string OriginalRole { get; set; } = ""; + public TotalsOverride Totals { get; set; } = TotalsOverride.Auto; + public TotalsOverride OriginalTotals { get; set; } = TotalsOverride.Auto; public string? Location { get; set; } public string? SerialNumber { get; set; } public string? Manufacturer { get; set; } @@ -549,11 +881,28 @@ /// The tank fields that change this meter's derived consumption: the calibration turns levels /// into volume. The burner rate only feeds the Consumables panel's rate figure. /// - public string TankSignature => FormattableString.Invariant($"{VolumePerCm}|{CalibrationOffset}"); + /// + /// The tank's unit is part of it too: the meter's usage is booked in it, and the recompute records that + /// unit with the meter's analysis data (D-20). + /// + public string TankSignature => FormattableString.Invariant($"{VolumePerCm}|{CalibrationOffset}|{TankUnit.Trim()}"); + public DateOnly? InstalledAtDate => InstalledAt is { } installed ? DateOnly.FromDateTime(installed) : null; + + public DateOnly? RetiredAtDate => RetiredAt is { } retired ? DateOnly.FromDateTime(retired) : null; + + /// + /// True when saving changes something the recompute reads: the mode, the register baseline, the install + /// date (it starts a first reading's interval, D-10), the tank calibration, or what the meter's amounts + /// are recorded as — its unit or role (D-20), which the rollup state stores. + /// public bool RecomputeNeeded => Mode != OriginalMode - || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9 - || (Mode == MeterMode.ConsumableBalance && WantsTank && TankSignature != OriginalTank); + || (Mode != MeterMode.Virtual + && (Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9 + || InstalledAtDate != OriginalInstalledAt + || !MeterVault.Core.Analysis.Quantities.Units.Comparer.Equals(Unit.Trim(), OriginalUnit) + || !string.Equals(Role, OriginalRole, StringComparison.OrdinalIgnoreCase) + || (Mode == MeterMode.ConsumableBalance && WantsTank && TankSignature != OriginalTank))); } } diff --git a/src/App/Components/Shared/MeterLists/MeterList.razor b/src/App/Components/Shared/MeterLists/MeterList.razor new file mode 100644 index 0000000..1c61496 --- /dev/null +++ b/src/App/Components/Shared/MeterLists/MeterList.razor @@ -0,0 +1,248 @@ +@using MeterVault.App.Energy +@inject NavigationManager Nav + +@* The one meter list (brief §7.3, §3.2): the global meter page and an energy type's Meters tab. Every row shows what + the meter measured in the chosen period — the canonical figure of the shared reader, a calculated meter's by its + formula — or its status in words when there is none (never a zero it did not measure), the data quality, and how it + counts in its type (counted, part of another meter, a calculated view). The name and the row open the meter's + Analysis tab for the same period; the quick entry adds a reading (or a tank level) without a detour. On a phone the + table turns into cards. *@ + +
+
+ + @if (ShowTypeFilter && _types.Count > 1) + { +
+ + @S.Meters_AllTypes + @foreach (var (id, name) in _types) + { + @name + } + +
+ } + + @(_filtered.Count == Rows.Count ? Loc.F(S.Meters_Count, Rows.Count) : Loc.F(S.Meters_CountFiltered, _filtered.Count, Rows.Count)) + +
+ + @if (Rows.Count == 0) + { + @EmptyContent + } + else + { + + + @S.Common_Name + @S.Common_Mode + @S.Meters_ColValue + @S.Meters_ColQuality + @S.Meters_ColCounts + @S.Common_Actions + + + +
+ @GroupName(context.Key) + @if (context.Key is int typeId) + { + + + @S.Meters_OpenEnergyType + + + } +
+
+
+ + +
+ @* A real link (keyboard, new tab) that must not also fire the row's click. *@ + + @context.Meter.Name + + @if (context.Meter.IsVirtual) + { + @* The mode column says it on wider screens; the card view hides that column. *@ + @MeterMode.Virtual.Display() + } + @if (!context.Meter.IsActive) + { + @S.Meters_Retired + } +
+
+ @context.Meter.Mode.Display() + +
+ @context.ValueText + @if (context.KindText is { } kind) + { +
@kind
+ } +
+
+ +
+ @if (context.QualityText is { } quality) + { + @quality + @if (context.QualityDetail is { } detail) + { +
@detail
+ } + @if (context.FreshnessText is { } freshness) + { +
@freshness
+ } + } + else + { + @Format.Unknown + } +
+
+ +
+ @if (context.Membership is { } membership) + { +
+
+ @if (!string.IsNullOrEmpty(membership.Detail)) + { +
@membership.Detail
+ } + } + else + { + @Format.Unknown + } +
+
+ + @* Buttons inside a clickable row must not also open the meter. *@ +
+ @if (MeterListRows.QuickEntry(context.Meter) is { } entry) + { + + + + } + @if (OnEdit.HasDelegate) + { + + } + @if (OnDelete.HasDelegate) + { + + } +
+
+
+ + @Loc.F(S.Meters_NoSearchMatch, _search ?? string.Empty) + +
+ } +
+ +@code { + /// The rows (), in display order. + [Parameter, EditorRequired] + public IReadOnlyList Rows { get; set; } = []; + + /// The page's analysis state: every link into a meter carries its period. + [Parameter] + public AnalysisQuery? Query { get; set; } + + /// Groups the rows by energy type, each group linking to its type page (the global list). + [Parameter] + public bool GroupByType { get; set; } + + /// Offers a filter by energy type (the global list). + [Parameter] + public bool ShowTypeFilter { get; set; } + + /// Shows an edit button per row (the page hosts the meter editor). + [Parameter] + public EventCallback OnEdit { get; set; } + + /// Shows a delete button per row (the page confirms and deletes). + [Parameter] + public EventCallback OnDelete { get; set; } + + /// What to show when there is no meter at all. + [Parameter] + public RenderFragment? EmptyContent { get; set; } + + private string? _search; + private int _typeFilter; + private List<(int Id, string Name)> _types = []; + private List _filtered = []; + private IReadOnlyList? _builtFrom; + + private readonly TableGroupDefinition _byType = new() + { + Indentation = false, + Expandable = false, + Selector = r => r.Meter.EnergyTypeId, + }; + + protected override void OnParametersSet() + { + if (!ReferenceEquals(_builtFrom, Rows)) + { + _builtFrom = Rows; + _types = [.. Rows.Select(r => (r.Meter.EnergyTypeId, r.Meter.EnergyTypeName)).Distinct()]; + if (_typeFilter != 0 && _types.All(t => t.Id != _typeFilter)) + { + _typeFilter = 0; + } + + Filter(); + } + } + + private void OnSearchChanged(string? search) + { + _search = search; + Filter(); + } + + private void OnTypeChanged(int typeId) + { + _typeFilter = typeId; + Filter(); + } + + private void Filter() => + _filtered = [.. Rows.Where(r => (_typeFilter == 0 || r.Meter.EnergyTypeId == _typeFilter) && MeterListRows.Matches(r, _search))]; + + private string GroupName(object? key) => + key is int id && _types.FirstOrDefault(t => t.Id == id) is { Name: { Length: > 0 } name } ? name : Format.Unknown; + + private void OnRowClick(TableRowClickEventArgs e) + { + if (e.Item is { } row) + { + Nav.NavigateTo(MeterLinks.Analysis(row.Meter.Id, Query)); + } + } +} diff --git a/src/App/Components/Shared/MeterLists/MeterList.razor.css b/src/App/Components/Shared/MeterLists/MeterList.razor.css new file mode 100644 index 0000000..8c2715e --- /dev/null +++ b/src/App/Components/Shared/MeterLists/MeterList.razor.css @@ -0,0 +1,73 @@ +.mv-meter-list__tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-bottom: 8px; +} + +.mv-meter-list__tools ::deep .mv-meter-list__search { + flex: 1 1 260px; + max-width: 440px; + margin-top: 0; +} + +.mv-meter-list__filter { + flex: 0 1 240px; + min-width: 180px; +} + +.mv-meter-list__tools ::deep .mv-meter-list__count { + margin-left: auto; +} + +.mv-meter-list__name { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 6px; + min-width: 0; + overflow-wrap: anywhere; +} + +.mv-meter-list__membership { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.mv-meter-list ::deep .mv-meter-list__actions { + text-align: right; + white-space: nowrap; +} + +.mv-meter-list ::deep .mv-meter-list__table td .mv-cell-secondary { + max-width: 34ch; +} + +.mv-meter-list__cell { + min-width: 0; +} + +/* The card view (below 600px) lays a cell out as label | content: the content keeps to the right edge. */ +@media (max-width: 599.98px) { + .mv-meter-list__cell { + text-align: end; + max-width: 70%; + } + + .mv-meter-list__cell .mv-meter-list__membership { + justify-content: flex-end; + } + + .mv-meter-list ::deep .mv-meter-list__table td .mv-cell-secondary { + max-width: none; + } +} + +/* In the table (600px and up) a name does not break between its words while there is room. */ +@media (min-width: 600px) { + .mv-meter-list__name { + min-width: 15ch; + } +} diff --git a/src/App/Components/Shared/MeterLists/_Imports.razor b/src/App/Components/Shared/MeterLists/_Imports.razor new file mode 100644 index 0000000..4326585 --- /dev/null +++ b/src/App/Components/Shared/MeterLists/_Imports.razor @@ -0,0 +1,2 @@ +@using MeterVault.Core.Analysis +@using MeterVault.Infrastructure.Analysis diff --git a/src/App/Components/Shared/MeterSearchDialog.razor b/src/App/Components/Shared/MeterSearchDialog.razor index 62b346e..a02b67c 100644 --- a/src/App/Components/Shared/MeterSearchDialog.razor +++ b/src/App/Components/Shared/MeterSearchDialog.razor @@ -4,8 +4,9 @@ @inject NavigationManager Nav @* Reachable from every page through the app bar: type part of a name, serial number or location and - either open the meter or go straight to entering its reading. Two taps from anywhere to the - keypad, which is what standing in a basement with a phone calls for. *@ + either open the meter's analysis — for the period the current page shows — or go straight to + entering its reading. Two taps from anywhere to the keypad, which is what standing in a basement + with a phone calls for. *@ @hit.Name @Describe(hit) @@ -67,11 +68,16 @@ private List? _meters; private string? _search; + // The period of the page the search was opened on (D-48): a result opens the meter's analysis for the same dates. + private AnalysisQuery _carried = AnalysisQuery.Default(AnalysisDefaults.History); + [CascadingParameter] private IMudDialogInstance? Dialog { get; set; } protected override async Task OnInitializedAsync() { + _carried = AnalysisQuery.Parse(Nav.Uri, AnalysisDefaults.ForPath(Nav.ToBaseRelativePath(Nav.Uri))); + await using var db = await DbFactory.CreateDbContextAsync(); var meters = await db.Meters.AsNoTracking() .Select(m => new MeterHit(m.Id, m.Name, m.EnergyType != null ? m.EnergyType.DisplayName : "—", m.Mode, m.IsActive, m.SerialNumber, m.Location)) @@ -127,7 +133,7 @@ { if (e.Key == "Enter" && Matches().FirstOrDefault() is { } first) { - Go(MeterLinks.Detail(first.Id)); + Go(MeterLinks.Analysis(first.Id, _carried)); } } @@ -135,7 +141,7 @@ { if (e.Key is "Enter" or " ") { - Go(MeterLinks.Detail(hit.Id)); + Go(MeterLinks.Analysis(hit.Id, _carried)); } } diff --git a/src/App/Components/Shared/SankeyChart.razor b/src/App/Components/Shared/SankeyChart.razor index b7e04dd..19efa89 100644 --- a/src/App/Components/Shared/SankeyChart.razor +++ b/src/App/Components/Shared/SankeyChart.razor @@ -9,7 +9,8 @@ } else { -
+ @* Wider than a phone: the diagram scrolls inside its own box (keyboard-focusable), never the page. *@ +
@((MarkupString)_svg)
} @@ -106,7 +107,7 @@ else var sb = new StringBuilder(); sb.Append(CultureInfo.InvariantCulture, - $""); + $""); // Ribbons first (under nodes). foreach (var link in Links) @@ -125,9 +126,17 @@ else var path = $"M{F(sx)},{F(sy0)} C{F(midX)},{F(sy0)} {F(midX)},{F(ty0)} {F(tx)},{F(ty0)} " + $"L{F(tx)},{F(ty0 + band)} C{F(midX)},{F(ty0 + band)} {F(midX)},{F(sy0 + band)} {F(sx)},{F(sy0 + band)} Z"; - var tip = Enc($"{label.GetValueOrDefault(link.From)} → {label.GetValueOrDefault(link.To)}: {Fmt(link.Value)}"); + var tip = Enc($"{label.GetValueOrDefault(link.From)} → {label.GetValueOrDefault(link.To)}: {Fmt(link.Value)}{Mark(link)}"); + var fill = Enc(color.GetValueOrDefault(link.From, "#607D8B")); + + // A calculation dependency (into a virtual sum) is outlined dashed and an estimated share (split across several + // upstream meters, or capped) dotted, so neither reads as metered flow; the tooltip and the table say it in words. + var outline = link.IsCalculated + ? $" stroke=\"{fill}\" stroke-opacity=\"0.8\" stroke-dasharray=\"4 3\"" + : link.IsEstimated ? " stroke=\"currentColor\" stroke-opacity=\"0.55\" stroke-dasharray=\"1.5 2.5\"" : ""; + var opacity = link.IsCalculated ? "0.22" : link.IsEstimated ? "0.28" : "0.38"; sb.Append(CultureInfo.InvariantCulture, - $"{tip}"); + $"{tip}"); } // Nodes + labels. @@ -143,7 +152,7 @@ else var anchor = rightmost ? "end" : "start"; var fill = Enc(node.ColorHex ?? "#607D8B"); var name = Enc(NodeLabel(node)); - var val = Enc(Fmt(node.Value)); + var val = Enc(NodeValue(node)); sb.Append(CultureInfo.InvariantCulture, $"{name}: {val}"); sb.Append(CultureInfo.InvariantCulture, @@ -164,6 +173,21 @@ else private string Fmt(double value) => $"{Format.Number(value)} {Unit}".Trim(); + /// + /// A node's figure: its amount, qualified when it is not complete ("1,234 kWh · Partial"), or — for a meter without a + /// number (no data, being prepared, invalid) — its status in words: it is drawn as a sliver, never labelled "0". + /// + private string NodeValue(FlowNode node) => node.Status switch + { + MeterVault.Core.Analysis.BucketStatus.Available => Fmt(node.Value), + MeterVault.Core.Analysis.BucketStatus.Partial => Fmt(node.Value) + " · " + node.Status.Display(), + _ => node.Status.Display(), + }; + + /// What kind of value a ribbon carries, when it is not a plain metered flow (D-30). + private static string Mark(FlowLink link) => + link.IsCalculated ? $" ({S.Sankey_LinkCalculated})" : link.IsEstimated ? $" ({S.Sankey_LinkEstimated})" : ""; + private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); private static string Enc(string value) => WebUtility.HtmlEncode(value); diff --git a/src/App/Components/Shared/SeriesChart.razor b/src/App/Components/Shared/SeriesChart.razor deleted file mode 100644 index 1b1f264..0000000 --- a/src/App/Components/Shared/SeriesChart.razor +++ /dev/null @@ -1,47 +0,0 @@ -@using ApexCharts - -@if (HasData) -{ - - @foreach (var series in Series) - { - - } - -} -else -{ - @S.Common_NoDataInRange -} - -@code { - /// A single (label, value) point in a series. - public sealed record Point(string Label, double Value); - - /// A named series rendered as bars or a line over the shared category axis. - public sealed record SeriesDef(string Name, SeriesType Type, IReadOnlyList Points); - - [Parameter, EditorRequired] - public IReadOnlyList Series { get; set; } = []; - - [Parameter] - public int Height { get; set; } = 300; - - [Parameter] - public int Decimals { get; set; } = 2; - - private bool HasData => Series.Any(s => s.Points.Count > 0); - - private readonly ApexChartOptions _options = new() - { - Theme = new Theme { Mode = Mode.Dark }, - DataLabels = new DataLabels { Enabled = false }, - Legend = new Legend { Position = LegendPosition.Top }, - Stroke = new Stroke { Width = 3, Curve = Curve.Smooth }, - }; -} diff --git a/src/App/Components/Shared/TrendChart.razor b/src/App/Components/Shared/TrendChart.razor deleted file mode 100644 index 95bfeb4..0000000 --- a/src/App/Components/Shared/TrendChart.razor +++ /dev/null @@ -1,32 +0,0 @@ -@using ApexCharts -@using System.Globalization - -@if (Points is { Count: > 0 }) -{ - - - -} -else -{ - @S.Common_NoDataInRange -} - -@code { - [Parameter, EditorRequired] - public IReadOnlyList Points { get; set; } = []; - - private readonly ApexChartOptions _options = new() - { - Theme = new Theme { Mode = Mode.Dark }, - DataLabels = new DataLabels { Enabled = false }, - }; - - // Qualified: ApexCharts also exports a `Format`, and this file imports it. - private static object Label(TrendPoint p) => MeterVault.App.Format.MonthLabel(p.Period); -} diff --git a/src/App/Components/_Imports.razor b/src/App/Components/_Imports.razor index 73e3eb8..462fa7f 100644 --- a/src/App/Components/_Imports.razor +++ b/src/App/Components/_Imports.razor @@ -8,6 +8,7 @@ @using Microsoft.JSInterop @using MudBlazor @using MeterVault.App +@using MeterVault.App.Analysis @using MeterVault.App.Localization @* UI strings live in Localization/Strings.resx and are reached through this alias: @S.Common_Save. Compiled properties, so a stale key fails the build. @Loc.F(...) formats the {0} ones. *@ @@ -15,6 +16,7 @@ @using MeterVault.App.Components @using MeterVault.App.Components.Layout @using MeterVault.App.Components.Shared +@using MeterVault.App.Components.Shared.Analysis @using MeterVault.Core.Domain @using MeterVault.Infrastructure.Costing @using MeterVault.Infrastructure.Dashboard diff --git a/src/App/Energy/EnergyAnalysis.cs b/src/App/Energy/EnergyAnalysis.cs new file mode 100644 index 0000000..6559f37 --- /dev/null +++ b/src/App/Energy/EnergyAnalysis.cs @@ -0,0 +1,248 @@ +using MeterVault.App.Analysis; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; +using MeterVault.Infrastructure.Dashboard; +using MeterVault.Infrastructure.Persistence; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.App.Energy; + +/// +/// The energy type page's own address keys beside the analysis keys (D-47): the tab () +/// and the History view — the type's totals, or its meters one by one. Both change what is shown, never what is read, +/// so switching them does not reload the analysis (D-46). +/// +public static class EnergyPageKeys +{ + /// The tab key (overview|history|flow|meters); Overview when absent. + public const string Tab = "tab"; + + /// The History view key (total|meters); the totals when absent. + public const string View = "view"; + + /// The type's totals per measure (D-22), never adding overlapping meters. + public const string ViewTotal = "total"; + + /// The type's meters side by side, with how each one counts. + public const string ViewMeters = "meters"; + + /// The History view a requested key opens: the meters for meters (any case), the totals otherwise. + public static string ResolveView(string? view) => + string.Equals(view?.Trim(), ViewMeters, StringComparison.OrdinalIgnoreCase) ? ViewMeters : ViewTotal; + + /// The tab and view an address names (an absolute URI, a relative URL, or just its query). + public static (string Tab, string View) Parse(string? uri) + { + var text = uri ?? string.Empty; + var start = text.IndexOf('?', StringComparison.Ordinal); + var query = start < 0 ? string.Empty : text[start..]; + var hash = query.IndexOf('#', StringComparison.Ordinal); + if (hash >= 0) + { + query = query[..hash]; + } + + var values = QueryHelpers.ParseQuery(query); + var tab = values.TryGetValue(Tab, out var t) ? t.ToString() : null; + var view = values.TryGetValue(View, out var v) ? v.ToString() : null; + return (AnalysisLinks.ResolveEnergyTab(tab), ResolveView(view)); + } +} + +/// An energy type as the page shows it (user data: its name is never translated). +public sealed record EnergyTypeFacts(int Id, string Name, string BaseUnit, string? Icon, string? ColorHex); + +/// +/// A meter as the meter lists show it beside its analysis: identity, what it is, and what the search looks through. +/// +/// A consumable-balance meter with its tank set up (it belongs on Tanks & consumables). +public sealed record MeterFacts( + int Id, + string Name, + int EnergyTypeId, + string EnergyTypeName, + MeterMode Mode, + string Unit, + bool IsActive, + string? SerialNumber, + string? Location, + bool HasTank) +{ + public bool IsVirtual => Mode == MeterMode.Virtual; + + /// Loads the meters of one energy type, or of every type, with their type's name. + public static async Task> LoadAsync(MeterVaultDbContext db, int? energyTypeId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(db); + + var meters = db.Meters.AsNoTracking(); + if (energyTypeId is { } typeId) + { + meters = meters.Where(m => m.EnergyTypeId == typeId); + } + + return await meters + .Select(m => new MeterFacts( + m.Id, + m.Name, + m.EnergyTypeId, + m.EnergyType != null ? m.EnergyType.DisplayName : string.Empty, + m.Mode, + m.Unit, + m.IsActive, + m.SerialNumber, + m.Location, + db.Tanks.Any(t => t.MeterId == m.Id))) + .ToListAsync(cancellationToken); + } +} + +/// +/// Everything the energy type page shows for one address, read once (D-46): the type, its meters, the resolved period, +/// the type's quantities with every meter's own series (one analysis read), its bill for the same buckets, the bill of +/// the comparison period, and the flow drawn from those very quantities. Every tab renders from this one value, so a +/// tab never shows another period's or another type's figures. +/// +/// The type the address names. +/// The type; null when it does not exist, and then nothing else is read. +/// The type's meters. +/// The resolved period (D-03). +/// The type's measures and every meter's series (). +/// +/// The plan the requested interval was refused with (too many points, D-05); the page then reads automatic buckets so +/// the totals, the flow and the meter list still show, and the toolbar offers a coarser interval. +/// +/// The type's bill in the quantities' buckets. +/// The comparison of the bill, priced in the paired buckets, when one applies. +/// The bill of the comparison period. +/// The flow graph of the same quantities (D-30). +/// Every energy type's name, for attention items about a type-scoped price. +public sealed record EnergyAnalysis( + int EnergyTypeId, + EnergyTypeFacts? Type, + IReadOnlyList Meters, + ResolvedPeriod Period, + AnalysisResult? Quantities, + BucketPlan? RefusedPlan, + CostAnalysis? Cost, + CostComparisonRequest? CostComparison, + CostAnalysis? ComparisonCost, + FlowGraph? Flow, + IReadOnlyDictionary EnergyTypeNames) +{ + private AttentionNames? _names; + private IReadOnlyList? _metrics; + + /// The names attention items and value details speak of. + public AttentionNames Names => _names ??= AttentionNames.From(Quantities, Cost, EnergyTypeNames); + + /// A meter's name (user data), or "Meter #id". + public string MeterName(int meterId) => Meters.FirstOrDefault(m => m.Id == meterId)?.Name ?? Names.Meter(meterId); + + /// What the History tab can chart for this type (). + public IReadOnlyList Metrics => _metrics ??= EnergyMetrics.Available(Quantities, Cost); + + /// The meter's place in the type's totals (D-22); null when the reader did not classify it. + public MeterTotalsEntry? TotalsOf(int meterId) => Quantities?.Classification.FirstOrDefault(c => c.MeterId == meterId)?.Entry; + + /// The type has a generation counter: its Solar view has something to show. + public bool HasGeneration => Meters.Any(m => m.Mode == MeterMode.GenerationCounter); + + /// The type has a tank set up: its Tanks & consumables view has something to show. + public bool HasTank => Meters.Any(m => m.Mode == MeterMode.ConsumableBalance && m.HasTank); + + /// The problems of both readers, for one attention list (duplicates collapse there). + public IEnumerable Problems => + (Quantities?.Problems ?? []).Concat(Cost?.QuantityProblems ?? []); + + /// + /// The bill's change against the comparison bill, by the one rule every page states it with (D-07, + /// ): the totals when both are complete, else the paired buckets both sides + /// have complete; not comparable without any. + /// + public CostChange CostChange => Cost is { } current && ComparisonCost is { } previous && CostComparison is { } comparison + ? OverviewComparison.Between(current.Buckets, current.Total, previous.Buckets, previous.Total, comparison.Pairs) + : CostChange.NoComparison; + + /// + /// Every standing charge in the bill (D-40): the type's and global rows and the meter fees on its bill lines — the + /// figure the Overview and the Analysis page show for the same scope. Null when the bill has none. + /// + public double? StandingCharge => Cost?.Total.StandingCharge; +} + +/// What an energy type's History can chart (D-47 metric=), and which one it shows. +public static class EnergyMetrics +{ + private static readonly AnalysisMetric[] Order = + [ + AnalysisMetric.Consumption, + AnalysisMetric.Generation, + AnalysisMetric.Export, + AnalysisMetric.Runtime, + AnalysisMetric.Net, + AnalysisMetric.Cost, + ]; + + /// + /// The metrics of the type's measures (D-22) and of its meters' own kinds (a signed net calculation has no measure + /// but can be charted meter by meter), in a fixed order, plus the cost when the type has a bill to show: billed + /// lines, a standing charge or manual costs — priced or not, since "not priced" is worth seeing. + /// + public static IReadOnlyList Available(AnalysisResult? quantities, CostAnalysis? cost) + { + var found = new HashSet(); + foreach (var series in (quantities?.Measures ?? []).Concat(quantities?.Series ?? [])) + { + if (AnalysisMetrics.MetricOf(series.Kind) is { } metric && metric != AnalysisMetric.Cost) + { + found.Add(metric); + } + } + + if (cost is not null && (cost.Lines.Count > 0 || cost.StandingCharges.Count > 0 || cost.ManualCosts.Bookings.Count > 0)) + { + found.Add(AnalysisMetric.Cost); + } + + return [.. Order.Where(found.Contains)]; + } + + /// The metric shown: the requested one when the type has it, else the first available (consumption first). + public static AnalysisMetric Effective(AnalysisMetric? requested, IReadOnlyList available) + { + ArgumentNullException.ThrowIfNull(available); + + if (requested is { } metric && available.Contains(metric)) + { + return metric; + } + + return available.Count > 0 ? available[0] : AnalysisMetric.Consumption; + } + + /// The measure series a quantity metric totals for the type (D-22), in measure order; empty for cost and net. + public static IReadOnlyList MeasuresOf(AnalysisResult? quantities, AnalysisMetric metric) + { + if (quantities is null) + { + return []; + } + + var measures = AnalysisMetrics.MeasuresOf(metric); + return [.. quantities.Measures + .Where(s => s.Key.Measure is { } m && measures.Contains(m)) + .OrderBy(s => measures.ToList().IndexOf(s.Key.Measure!.Value)) + .ThenBy(s => s.Unit, StringComparer.Ordinal)]; + } + + /// The meters' own series of a quantity metric's kind, in meter order. + public static IReadOnlyList MetersOf(AnalysisResult? quantities, AnalysisMetric metric) => + quantities is null || AnalysisMetrics.QuantityKindOf(metric) is not { } kind + ? [] + : [.. quantities.Series.Where(s => s.Kind == kind)]; +} diff --git a/src/App/Energy/EnergyAnalysisLoader.cs b/src/App/Energy/EnergyAnalysisLoader.cs new file mode 100644 index 0000000..63a9e32 --- /dev/null +++ b/src/App/Energy/EnergyAnalysisLoader.cs @@ -0,0 +1,80 @@ +using MeterVault.App.Analysis; +using MeterVault.Core.Analysis; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; +using MeterVault.Infrastructure.Dashboard; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.App.Energy; + +/// +/// Reads what the energy type page shows for one address, once (brief §7.3, D-46): the type and its meters, the resolved +/// period, the type's measures with every meter's own series, its bill in the same buckets, the bill of the comparison +/// period priced in the paired buckets (A-10), and the flow drawn from those very quantities. +/// +public sealed class EnergyAnalysisLoader( + IDbContextFactory dbFactory, AnalysisPeriods periods, AnalysisReader reader, CostReader costs, FlowService flow) +{ + /// What the read depends on: the metric only picks what History charts, the scope is the route's. + public static AnalysisQuery LoadKey(AnalysisQuery query, int energyTypeId) + { + ArgumentNullException.ThrowIfNull(query); + + return query.WithMetric(null).WithScope(QueryScope.ForEnergyType(Math.Max(1, energyTypeId))); + } + + /// Reads energy type for as of . + public async Task LoadAsync(int id, AnalysisQuery query, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + // "All" spans the type's quantity coverage whatever metric History shows, so every tab shares one period. + var scoped = LoadKey(query, id); + var period = await periods.ResolveAsync(scoped, now, cancellationToken).ConfigureAwait(false); + + Dictionary typeNames; + EnergyTypeFacts? type = null; + List meters = []; + await using (var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false)) + { + var types = await db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); + typeNames = types.ToDictionary(t => (int)t.Id, t => t.DisplayName); + if (types.FirstOrDefault(t => t.Id == id) is { } found) + { + type = new EnergyTypeFacts(found.Id, found.DisplayName, found.BaseUnit, found.Icon, found.ColorHex); + meters = await MeterFacts.LoadAsync(db, id, cancellationToken).ConfigureAwait(false); + } + } + + if (type is null || meters.Count == 0) + { + return new EnergyAnalysis(id, type, meters, period, null, null, null, null, null, null, typeNames); + } + + var request = new AnalysisRequest(AnalysisScope.ForEnergyType(id), period) + { + Bucket = query.Bucket, + Comparison = query.Comparison, + IncludeMeterSeries = true, + }; + var quantities = await reader.ReadAsync(request, cancellationToken).ConfigureAwait(false); + BucketPlan? refused = null; + if (quantities.Refusal == AnalysisRefusal.TooManyPoints) + { + // The toolbar offers a coarser interval; meanwhile the totals, the flow and the meters still show. + refused = quantities.Plan; + quantities = await reader.ReadAsync(request with { Bucket = BucketSize.Auto }, cancellationToken).ConfigureAwait(false); + } + + var costRequest = new CostAnalysisRequest(CostScope.ForEnergyType(id), period) { Plan = quantities.Plan }; + var cost = await costs.ReadAsync(costRequest, cancellationToken).ConfigureAwait(false); + var comparison = query.ToCostComparison(costRequest, cost.Plan); + var previous = comparison.Request is { } previousRequest + ? await costs.ReadAsync(previousRequest, cancellationToken).ConfigureAwait(false) + : null; + var graph = await flow.FromResultAsync((short)id, quantities, cancellationToken).ConfigureAwait(false); + + return new EnergyAnalysis(id, type, meters, period, quantities, refused, cost, comparison, previous, graph, typeNames); + } +} diff --git a/src/App/Energy/EnergyHistoryView.cs b/src/App/Energy/EnergyHistoryView.cs new file mode 100644 index 0000000..8fb4c98 --- /dev/null +++ b/src/App/Energy/EnergyHistoryView.cs @@ -0,0 +1,144 @@ +using MeterVault.App.Analysis; +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.App.Energy; + +/// +/// What the History tab draws for one metric and view (brief §7.3): the chart and table series, the series the +/// comparison summary speaks of, and — for the individual view — the meters shown, those left out, and how each one +/// counts (a breakdown of another, an analysis-only calculation), so the reader never adds overlapping meters up. +/// +/// The metric shown. +/// The meters one by one rather than the type's totals. +/// The chart series (with comparison overlays in the totals view). +/// The table series. +/// The series the comparison summary and the empty state speak of; null for cost or nothing to show. +/// The coarsest resolution among the charted series, which bounds a drill-down (D-51). +/// For the individual view: the meters charted, in meter order. +/// For the individual view: how many more meters of the metric there are than a chart can hold. +/// For the individual view: how each charted meter counts, when it is not simply counted. +public sealed record EnergyHistoryView( + AnalysisMetric Metric, + bool IsIndividual, + IReadOnlyList Chart, + IReadOnlyList Table, + AnalysisSeries? Main, + ResolutionClass? Coarsest, + IReadOnlyList Shown, + int Hidden, + IReadOnlyList<(AnalysisSeries Series, MeterMembership Membership)> Memberships) +{ + /// Nothing to chart: the type has no total (or no meter) for the metric. + public bool IsEmpty => Chart.Count == 0; + + /// True when every charted quantity series lacks data for the whole period (not a zero: nothing). + public bool HasNoData(IReadOnlyList series) => + series.Count > 0 && series.All(s => s.Total.Status == BucketStatus.Missing); + + /// Builds the view of for . + /// The page's committed value. + /// The metric shown (). + /// The individual meters rather than the totals. + /// The comparison asked for, for the overlays' names. + public static EnergyHistoryView Build(EnergyAnalysis analysis, AnalysisMetric metric, bool individual, ComparisonRequest comparison) + { + ArgumentNullException.ThrowIfNull(analysis); + ArgumentNullException.ThrowIfNull(comparison); + + var quantities = analysis.Quantities; + if (metric == AnalysisMetric.Cost) + { + return individual ? Empty(metric, individual) : CostView(analysis, comparison); + } + + if (!individual) + { + var measures = EnergyMetrics.MeasuresOf(quantities, metric); + List chart = []; + foreach (var series in measures) + { + var name = MeasureName(series, measures); + chart.Add(AnalysisChartSeries.ForSeries(series, name, meterName: analysis.MeterName)); + if (AnalysisChartSeries.ComparisonOf(series, AnalysisChartSeries.ComparisonName(name, comparison), meterName: analysis.MeterName) is { } overlay) + { + chart.Add(overlay); + } + } + + return new EnergyHistoryView( + metric, + false, + chart, + [.. measures.Select(s => AnalysisTableSeries.ForSeries(s, MeasureName(s, measures), analysis.MeterName))], + measures.FirstOrDefault(), + CoarsestOf(measures), + [], + 0, + []); + } + + var all = EnergyMetrics.MetersOf(quantities, metric); + var shown = all.Take(AnalysisLimits.MaxSeries).ToList(); + var memberships = new List<(AnalysisSeries, MeterMembership)>(); + foreach (var series in shown) + { + if (MeterMembership.Of(analysis.TotalsOf(series.MeterId!.Value), series, analysis.MeterName) is { } membership) + { + memberships.Add((series, membership)); + } + } + + return new EnergyHistoryView( + metric, + true, + [.. shown.Select(s => AnalysisChartSeries.ForSeries(s, meterName: analysis.MeterName))], + [.. shown.Select(s => AnalysisTableSeries.ForSeries(s, meterName: analysis.MeterName))], + shown.FirstOrDefault(), + CoarsestOf(shown), + shown, + all.Count - shown.Count, + memberships); + } + + /// + /// A measure's name: its wording, with the unit when the metric has several groups of it ("Total use (kWh)"), since a + /// measure is never added across units. + /// + public static string MeasureName(AnalysisSeries series, IReadOnlyList siblings) + { + ArgumentNullException.ThrowIfNull(series); + ArgumentNullException.ThrowIfNull(siblings); + + var name = AnalysisChartSeries.NameOf(series); + return siblings.Count(s => s.Key.Measure == series.Key.Measure) > 1 ? Loc.F(Strings.EnergyView_MeasureUnit, name, series.Unit) : name; + } + + private static EnergyHistoryView CostView(EnergyAnalysis analysis, ComparisonRequest comparison) + { + if (analysis.Cost is not { } cost) + { + return Empty(AnalysisMetric.Cost, false); + } + + const string key = "cost"; + var name = Strings.AnalysisTable_Cost; + List chart = [AnalysisChartSeries.ForCost(key, name, cost.Currency, cost.Buckets)]; + var table = AnalysisTableSeries.ForCosts(key, name, cost.Currency, cost.Buckets, cost.Total); + if (analysis.ComparisonCost is { } previous && previous.Buckets.Count == cost.Buckets.Count) + { + chart.Add(AnalysisChartSeries.ComparisonForCost(key, AnalysisChartSeries.ComparisonName(name, comparison), cost.Currency, previous.Buckets)); + table = table.WithComparisonCosts(previous.Buckets, previous.Total, cost.Currency); + } + + // A bill cannot be drilled finer than the quantities it prices resolve (a monthly import stays monthly, D-51). + var priced = cost.Lines.Select(l => analysis.Quantities?.SeriesFor(l.MeterId)).OfType(); + return new EnergyHistoryView(AnalysisMetric.Cost, false, chart, [table], null, CoarsestOf(priced), [], 0, []); + } + + private static EnergyHistoryView Empty(AnalysisMetric metric, bool individual) => new(metric, individual, [], [], null, null, [], 0, []); + + private static ResolutionClass? CoarsestOf(IEnumerable series) => + series.Select(s => s.Resolution).Where(r => r is not null).Max(); +} diff --git a/src/App/Energy/FlowText.cs b/src/App/Energy/FlowText.cs new file mode 100644 index 0000000..a6aa101 --- /dev/null +++ b/src/App/Energy/FlowText.cs @@ -0,0 +1,113 @@ +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Infrastructure.Dashboard; + +namespace MeterVault.App.Energy; + +/// +/// The words of the Flow tab's table equivalent (brief §7.3, D-30): what each ribbon is — a metered part, an input of a +/// calculated sum, an estimated share, a capped one, the unmetered remainder — and why a meter is not in the diagram. +/// +public static class FlowText +{ + /// A node's name: the meter's, or "Other (…)" for a remainder. + public static string NodeName(FlowGraph graph, string nodeId) + { + ArgumentNullException.ThrowIfNull(graph); + + return graph.Nodes.FirstOrDefault(n => n.Id == nodeId) is { } node + ? node.IsOther ? Loc.F(Strings.Flow_OtherNode, node.Label) : node.Label + : nodeId; + } + + /// What a ribbon carries, in words. + public static string EdgeKind(FlowGraph graph, FlowLink link) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(link); + + if (graph.Nodes.FirstOrDefault(n => n.Id == link.To) is { IsOther: true }) + { + return Strings.EnergyView_EdgeRemainder; + } + + if (link.IsCalculated) + { + return Strings.EnergyView_EdgeCalculated; + } + + if (link.IsCapped) + { + return Loc.F(Strings.EnergyView_EdgeCapped, NodeName(graph, link.From)); + } + + return link.IsEstimated ? Strings.EnergyView_EdgeEstimated : Strings.EnergyView_EdgeMeasured; + } + + /// + /// Why a meter of the type is not a node of the diagram; null when it is. A meter in another unit (a flow never adds + /// units), a calculation that is not a plain sum (it has no place among non-negative ribbons), or nothing to draw. + /// + public static string? NotDrawnReason(FlowGraph graph, FlowMeter meter) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(meter); + + if (meter.InDiagram) + { + return null; + } + + if (!Units.AreSame(meter.Unit, graph.Unit)) + { + return Loc.F(Strings.EnergyView_NotDrawnUnit, meter.Unit); + } + + if (meter.IsVirtual) + { + return Strings.EnergyView_NotDrawnCalculation; + } + + return Strings.EnergyView_NotDrawnNoAmount; + } + + /// A meter's signed period total with its unit, or its status in words — never a zero it did not measure. + public static string MeterValue(FlowMeter meter) + { + ArgumentNullException.ThrowIfNull(meter); + + if (meter.Value is not { } value) + { + return meter.Status == BucketStatus.Available ? Format.Unknown : meter.Status.Display(); + } + + var text = Format.Quantity(value, meter.Unit); + return meter.Status == BucketStatus.Available ? text : text + " · " + meter.Status.Display(); + } + + /// + /// Why a connection cannot be added or removed, in words, with the meters named (the loop spelled out as + /// "Haus → Auto → Netz"). + /// + public static string Refusal(MeterLinkCheck check, Func name, int toMeterId) + { + ArgumentNullException.ThrowIfNull(check); + ArgumentNullException.ThrowIfNull(name); + + return check.Refusal switch + { + MeterLinkRefusal.None => string.Empty, + MeterLinkRefusal.SameMeter => Strings.EnergyView_RefusedSameMeter, + MeterLinkRefusal.UnknownMeter => Strings.EnergyView_RefusedUnknownMeter, + MeterLinkRefusal.OtherEnergyType => Strings.EnergyView_RefusedOtherEnergyType, + MeterLinkRefusal.AlreadyLinked => Strings.EnergyView_RefusedAlreadyLinked, + MeterLinkRefusal.WouldCreateCycle => Loc.F( + Strings.EnergyView_RefusedCycle, + string.Join(" → ", check.Path.Append(check.Path.Count > 0 ? check.Path[0] : toMeterId).Select(name))), + MeterLinkRefusal.CalculatedFromLinks => Loc.F(Strings.EnergyView_RefusedCalculatedFromLinks, name(toMeterId)), + MeterLinkRefusal.NotFound => Strings.EnergyView_RefusedNotFound, + _ => check.Refusal.ToString(), + }; + } +} diff --git a/src/App/Energy/MeterChanges.cs b/src/App/Energy/MeterChanges.cs new file mode 100644 index 0000000..5d66c2e --- /dev/null +++ b/src/App/Energy/MeterChanges.cs @@ -0,0 +1,49 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.App.Energy; + +/// +/// A meter's change against the comparison, measured over the dates both periods cover (D-07): the matched values, not +/// the requested totals, so a meter that started in the middle of last year is not "up 100 %". +/// +/// The meter's series. +/// Its value over the matched range of this period. +/// Its value over the matched range of the comparison. +/// The change between them (D-08). +/// The matched ranges. +public sealed record MeterChange(AnalysisSeries Series, double Current, double Previous, Change Change, MatchedCoverageResult Matched); + +/// The Overview tab's "largest changes by meter" (brief §7.3). +public static class MeterChanges +{ + /// + /// The meters with a comparable change (matched coverage, both values known; indicators never), largest first: by + /// the absolute difference when they all share a unit, else — amounts in different units do not rank against each + /// other — by the percentage, those without an applicable percentage last. + /// + /// The meters' series () of a read with a comparison. + /// How many to return. + public static IReadOnlyList Largest(IEnumerable series, int max) + { + ArgumentNullException.ThrowIfNull(series); + ArgumentOutOfRangeException.ThrowIfNegative(max); + + var changes = series + .Where(s => s.MeterId is not null && s.Kind != QuantityKind.Indicator) + .Select(s => s.Comparison is { Matched.IsComparable: true, CurrentMatched: { } current, ComparisonMatched: { } previous } comparison + && comparison.Change is { IsAvailable: true } change + ? new MeterChange(s, current, previous, change, comparison.Matched) + : null) + .OfType() + .ToList(); + + var oneUnit = changes.Select(c => Core.Analysis.Quantities.Units.Normalize(c.Series.Unit)).Distinct(StringComparer.OrdinalIgnoreCase).Count() <= 1; + IOrderedEnumerable ordered = oneUnit + ? changes.OrderByDescending(c => Math.Abs(c.Change.Absolute!.Value)) + : changes.OrderBy(c => c.Change.Percent is null).ThenByDescending(c => Math.Abs(c.Change.Percent ?? 0)).ThenByDescending(c => Math.Abs(c.Change.Absolute!.Value)); + + return [.. ordered.ThenBy(c => c.Series.Name, StringComparer.CurrentCultureIgnoreCase).Take(max)]; + } +} diff --git a/src/App/Energy/MeterListRows.cs b/src/App/Energy/MeterListRows.cs new file mode 100644 index 0000000..be1fda6 --- /dev/null +++ b/src/App/Energy/MeterListRows.cs @@ -0,0 +1,164 @@ +using MeterVault.App.Analysis; +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.App.Energy; + +/// +/// One row of a meter list — the global list (/meters) and an energy type's Meters tab share it: the meter, its +/// canonical series for the chosen period (a physical meter's rollups, a virtual meter's formula, D-27) and how it +/// counts in its type's totals (D-22). The figure is the reader's: a meter without data says so in words, never "0". +/// +/// The meter. +/// Its series for the period; null when it was not read (added after the read). +/// How it counts; null when it was not classified. +/// The period total's status in words (a derived value names the source it misses); null when not read. +public sealed record MeterListRow(MeterFacts Meter, AnalysisSeries? Series, MeterMembership? Membership, FigureStatus? Status) +{ + + /// + /// The period total with its unit (signed), or its status in words ("No data", "Being prepared") when there is no + /// number — never a made-up zero; "—" when the meter was not read. + /// + public string ValueText => Series is null || Status is null + ? Format.Unknown + : Status.IsKnown ? Format.Quantity(Series.Total.Value, Series.Unit) : Status.Status; + + /// True when is a number. + public bool HasValue => Status?.IsKnown == true; + + /// What the value measures, in words ("Consumption", "Generation"). + public string? KindText => Series?.Kind.Display(); + + /// + /// The data quality in one line: availability and provenance ("Complete · Imported"), then the resolution + /// ("Monthly") when known. + /// + public string? QualityText + { + get + { + if (Status is null) + { + return null; + } + + var parts = new List(2) { Status.Summary }; + if (Series?.Resolution is { } resolution) + { + parts.Add(resolution.Display()); + } + + return string.Join(" · ", parts); + } + } + + /// + /// Why there is no number, or which source a calculated figure misses — the one detail the status line does not + /// already say ("Partial" needs no "covers only part of this period"); null otherwise. The full reason is the + /// status line's tooltip (). + /// + public string? QualityDetail => + Status is { IsQualified: true, Detail: { } detail } && (!Status.IsKnown || Series?.Total.DependencyPath is { Count: > 1 }) ? detail : null; + + /// + /// How current the data is ("Live", "Historical …") and the last day it covers; null without data. The last day is the + /// coverage's, not a reading's stamp: a monthly import stamped on the 1st covers its whole month. + /// + public string? FreshnessText + { + get + { + if (Series?.Freshness is not { } freshness || freshness.State == FreshnessState.NoData) + { + return null; + } + + return Series.Availability is { } available + ? Loc.F(Strings.Meters_FreshnessUntil, freshness.State.Display(), Format.Date(available.LastDay)) + : freshness.State.Display(); + } + } +} + +/// Builds, orders and searches meter list rows. +public static class MeterListRows +{ + /// + /// A row per meter with its series and membership from (a type or portfolio read with + /// ), ordered by energy type, then meters in service before retired + /// ones, then name. + /// + public static IReadOnlyList Build(IEnumerable meters, AnalysisResult? result) + { + ArgumentNullException.ThrowIfNull(meters); + + var list = meters.ToList(); + var names = list.ToDictionary(m => m.Id, m => m.Name); + string Name(int id) => names.TryGetValue(id, out var name) ? name : result?.SeriesFor(id)?.Name ?? MeterMembership.FallbackName(id); + + return [.. list + .Select(meter => + { + var series = result?.SeriesFor(meter.Id); + var entry = result?.Classification.FirstOrDefault(c => c.MeterId == meter.Id)?.Entry; + var status = series is null ? null : FigureText.Of(series.Total, id => Name(id)); + return new MeterListRow(meter, series, MeterMembership.Of(entry, series, Name), status); + }) + .OrderBy(r => r.Meter.EnergyTypeName, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(r => r.Meter.EnergyTypeId) + .ThenBy(r => !r.Meter.IsActive) + .ThenBy(r => r.Meter.Name, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(r => r.Meter.Id)]; + } + + /// + /// True when is blank or found (current culture, ignoring case) in the meter's name, serial + /// number, location, energy type, mode or how it counts. + /// + public static bool Matches(MeterListRow row, string? search) + { + ArgumentNullException.ThrowIfNull(row); + + if (string.IsNullOrWhiteSpace(search)) + { + return true; + } + + var term = search.Trim(); + return Contains(row.Meter.Name) || Contains(row.Meter.SerialNumber) || Contains(row.Meter.Location) + || Contains(row.Meter.EnergyTypeName) || Contains(row.Meter.Mode.Display()) || Contains(row.Membership?.Label); + + bool Contains(string? value) => value?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true; + } + + /// The quick entry of a row () with its label and icon; null for a virtual meter. + public static (string Href, string Label, bool IsTank)? QuickEntry(MeterFacts meter) + { + ArgumentNullException.ThrowIfNull(meter); + + if (MeterLinks.QuickEntry(meter.Id, meter.Mode) is not { } href) + { + return null; + } + + var tank = meter.Mode == MeterMode.ConsumableBalance; + return (href, tank ? Strings.Meters_QuickTankLevel : Strings.Meters_QuickReading, tank); + } + + /// + /// A quiet icon beside how a meter counts (counted, part of another, a calculated view, left out); the words carry + /// the meaning, so it is hidden from screen readers. + /// + public static string MembershipIcon(MeterMembership? membership) => membership?.Class switch + { + null => MudBlazor.Icons.Material.Outlined.HelpOutline, + MeterTotalsClass.Breakdown => MudBlazor.Icons.Material.Outlined.SubdirectoryArrowRight, + MeterTotalsClass.AnalysisOnly => MudBlazor.Icons.Material.Outlined.Functions, + MeterTotalsClass.ExcludedByOverride or MeterTotalsClass.NotCounted => MudBlazor.Icons.Material.Outlined.Block, + _ => MudBlazor.Icons.Material.Outlined.CheckCircleOutline, + }; +} diff --git a/src/App/Energy/MeterMembership.cs b/src/App/Energy/MeterMembership.cs new file mode 100644 index 0000000..d852915 --- /dev/null +++ b/src/App/Energy/MeterMembership.cs @@ -0,0 +1,70 @@ +using System.Globalization; +using MeterVault.App.Localization; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.App.Energy; + +/// +/// How a meter counts in its energy type's totals (D-22, D-23), in the reader's words: the class ("Breakdown of a +/// counted meter") and one sentence naming the meters it relates to ("Part of Zähler Haus: shown, never added on top"). +/// This is what the History tab's individual view and the meter lists say about overlap, so nobody adds a breakdown to +/// its parent or a calculated view to its sources. +/// +/// Where the meter ends up. +/// The class in words. +/// Why, with the related meters named; empty when there is nothing to add. +public sealed record MeterMembership(MeterTotalsClass Class, string Label, string Detail) +{ + /// True when the meter adds to a measure of its type. + public bool IsCounted => Class is MeterTotalsClass.Use or MeterTotalsClass.GridImport or MeterTotalsClass.Export + or MeterTotalsClass.Generation or MeterTotalsClass.Runtime or MeterTotalsClass.IncludedByOverride; + + /// + /// The membership of a classified meter; null without a classification (a meter scope, a meter added after the + /// read). + /// + /// The meter's classification (). + /// Its series, for a virtual meter's sources; may be null. + /// Names a meter id (user data). + public static MeterMembership? Of(MeterTotalsEntry? entry, AnalysisSeries? series, Func name) + { + ArgumentNullException.ThrowIfNull(name); + + if (entry is null) + { + return null; + } + + string Names(IEnumerable ids) => string.Join(", ", ids.Distinct().Select(name)); + string Related() => entry.RelatedMeterId is { } related ? name(related) : Format.Unknown; + + var sources = series?.Virtual?.DirectSources ?? []; + var detail = entry.Reason switch + { + MeterTotalsReason.TotalLoadRole => Strings.EnergyView_WhyTotalLoad, + MeterTotalsReason.ConsumptionRoot => Strings.EnergyView_WhyConsumptionRoot, + MeterTotalsReason.ContainedByLink when entry.ParentIds.Count > 0 => Loc.F(Strings.EnergyView_WhyBreakdown, Names(entry.ParentIds)), + MeterTotalsReason.AssumedInsideTotalLoad when entry.ParentIds.Count > 0 => Loc.F(Strings.EnergyView_WhyAssumedInside, Names(entry.ParentIds)), + MeterTotalsReason.GridImportRole => Strings.EnergyView_WhyGridImport, + MeterTotalsReason.GridExportRole => Strings.EnergyView_WhyExport, + MeterTotalsReason.GenerationMeter => Strings.EnergyView_WhyGeneration, + MeterTotalsReason.RuntimeMeter => Strings.EnergyView_WhyRuntime, + MeterTotalsReason.VirtualView when sources.Count > 0 => Loc.F(Strings.EnergyView_WhyVirtualView, Names(sources)), + MeterTotalsReason.VirtualView => Strings.EnergyView_WhyVirtualViewPlain, + MeterTotalsReason.OverrideNever => Strings.EnergyView_WhyOverrideNever, + MeterTotalsReason.CoveredByOverride => Loc.F(Strings.EnergyView_WhyCoveredBy, Related()), + MeterTotalsReason.OverrideAlways when entry.ReplacesIds.Count > 0 => Loc.F(Strings.EnergyView_WhyOverrideAlways, Names(entry.ReplacesIds)), + MeterTotalsReason.OverrideAlways => Strings.EnergyView_WhyOverrideAlwaysPlain, + MeterTotalsReason.DuplicateRole => Loc.F(Strings.EnergyView_WhyDuplicateRole, Related()), + MeterTotalsReason.ContainsTotalLoad => Loc.F(Strings.EnergyView_WhyContainsTotalLoad, Related()), + _ => string.Empty, + }; + + return new MeterMembership(entry.Class, entry.Class.Display(), detail); + } + + /// The fallback name of a meter id nobody named ("Meter #12"). + public static string FallbackName(int meterId) => + Loc.F(Strings.Attention_MeterFallback, meterId.ToString(CultureInfo.CurrentCulture)); +} diff --git a/src/App/Format.cs b/src/App/Format.cs index 4ae3528..90844de 100644 --- a/src/App/Format.cs +++ b/src/App/Format.cs @@ -1,4 +1,7 @@ using System.Globalization; +using System.Text.RegularExpressions; +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; namespace MeterVault.App; @@ -6,24 +9,80 @@ namespace MeterVault.App; /// Small display formatters for the UI. ///
/// +/// /// Everything formats against , which the request /// localization middleware sets from the reader's chosen UI language — so 1234.5 renders as /// "1.234,5" for a German reader and "1,234.5" for an English one, from the same call site. This is /// display only: the CSV importer still parses the spreadsheet dialect with an explicit de-DE /// culture (), because that dialect is a property of the /// files, not of who is looking at them. +/// +/// +/// Values are rounded here and nowhere else: calculations keep full precision, and a value that rounds to zero is +/// written as a plain zero, never "-0,00". +/// /// -public static class Format +public static partial class Format { - /// - /// A money amount. The symbol is the euro rather than the culture's own, because the figure is - /// in the instance's configured currency — switching UI language must not restate the amount as - /// dollars. Only the digit grouping follows the reader. - /// - public static string Euro(double value) => value.ToString("N2", CultureInfo.CurrentCulture) + " €"; + /// What an unknown value reads as: a dash, never a fabricated zero. + public const string Unknown = "—"; + private const string RangeSeparator = " – "; + + /// + /// A money amount in (an ISO code, D-43): the number as the reader writes it, a space, + /// and the currency's symbol ("1.234,50 €", "1,234.50 €"). The symbol is the instance currency's, not the + /// culture's — switching UI language must not restate the amount as dollars. Only the digits follow the reader. + /// + public static string Money(double value, string? currency) => Number(value, 2) + " " + CurrencySymbol(currency); + + /// A money amount, or when it is not known. + public static string Money(double? value, string? currency) => value is { } amount ? Money(amount, currency) : Unknown; + + /// A money amount with an explicit sign: "+12,00 €" for a rise, "-3,50 €" for a fall, "0,00 €" for none. + public static string MoneySigned(double value, string? currency) + { + var shown = Clean(value, 2); + return (shown > 0 ? "+" : string.Empty) + Money(shown, currency); + } + + /// + /// The symbol of an ISO currency code: € for EUR, $ for USD, £ for GBP, CHF for CHF, and the code itself for any + /// other (upper-cased). Blank reads as EUR, the instance default. + /// + public static string CurrencySymbol(string? currency) + { + var code = string.IsNullOrWhiteSpace(currency) ? "EUR" : currency.Trim().ToUpperInvariant(); + return code switch + { + "EUR" => "€", + "USD" => "$", + "GBP" => "£", + _ => code, + }; + } + + /// + /// A number with places, grouped the reader's way. Halves round away from zero, as a + /// spreadsheet does (1234.5 → "1,235"), and anything that rounds to zero is a plain zero. + /// public static string Number(double value, int decimals = 0) => - value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.CurrentCulture); + Clean(value, decimals).ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.CurrentCulture); + + /// + /// A quantity with its unit ("1.234 kWh"), or when it is not known. Without + /// the precision follows the size: none from 10 up, one from 1, two below. + /// + public static string Quantity(double? value, string? unit, int? decimals = null) + { + if (value is not { } amount || !double.IsFinite(amount)) + { + return Unknown; + } + + var text = Number(amount, decimals ?? AutoDecimals(amount)); + return string.IsNullOrWhiteSpace(unit) ? text : text + " " + unit.Trim(); + } public static string Percent(double value) => (value >= 0 ? "+" : "") + value.ToString("N1", CultureInfo.CurrentCulture) + " %"; @@ -38,4 +97,184 @@ public static class Format < 0 => "▼", _ => "—", }; + + /// A month with its year in the reader's words: "Sep 2026" / "Sept. 2026" (the day is ignored). + public static string MonthYear(DateOnly month) => + new DateOnly(month.Year, month.Month, 1).ToString(YearMonthPattern(), CultureInfo.CurrentCulture); + + /// A local date in the reader's words: "Sep 19, 2026" / "19. Sept. 2026", or without the year ("Sep 19"). + public static string Date(DateOnly date, bool includeYear = true) => + date.ToString(includeYear ? DayMonthYearPattern() : DayMonthPattern(), CultureInfo.CurrentCulture); + + /// + /// An inclusive range of local days: "Aug 19 – Sep 19, 2026"; the year on both ends when they differ, on the last + /// only when they share it, and nowhere with false (a chart within one year). One day + /// reads as that day. + /// + public static string DateRange(DateOnly first, DateOnly last, bool includeYear = true) + { + if (last <= first) + { + return Date(first, includeYear); + } + + var firstWithYear = includeYear && first.Year != last.Year; + return Date(first, firstWithYear) + RangeSeparator + Date(last, includeYear); + } + + /// + /// The dates a resolved period actually covers, for display next to its preset (brief §4.1): up to today for a + /// to-date period, the requested dates for one that has not started, for "no history". + /// + public static string PeriodRange(ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(period); + + if (period.HasNoHistory()) + { + return Unknown; + } + + return period.HasNotStarted() + ? DateRange(period.FirstDay, period.LastDay) + : DateRange(period.FirstDay, period.EffectiveLastDay()); + } + + /// True when buckets from to need the year in their labels. + public static bool SpansYears(DateOnly first, DateOnly last) => first.Year != last.Year; + + /// True when a series of buckets crosses a year boundary, so its labels carry the year. + public static bool SpansYears(IReadOnlyList buckets) + { + ArgumentNullException.ThrowIfNull(buckets); + + return buckets.Count > 0 && SpansYears(buckets[0].FirstDay, LastDayOf(buckets[^1])); + } + + /// + /// The label of a chart or table bucket. A whole day, month or year reads as that unit ("Sep 19", "Sep 2026", + /// "2026"); a month or year cut short at now still reads as its unit, since it is that unit so far. A week — and a + /// month or year clipped by a custom range — shows its real first and last day, so a partial week never looks + /// like a whole one. With (set it from ) + /// every label names its year. + /// + public static string BucketLabel(AnalysisBucket bucket, bool includeYear) + { + ArgumentNullException.ThrowIfNull(bucket); + + var last = LastDayOf(bucket); + switch (bucket.Size) + { + case BucketSize.Day: + return Date(bucket.FirstDay, includeYear); + + case BucketSize.Month: + var monthStart = new DateOnly(bucket.FirstDay.Year, bucket.FirstDay.Month, 1); + return IsWholeUnit(bucket, monthStart, monthStart.AddMonths(1)) + ? bucket.FirstDay.ToString(includeYear ? YearMonthPattern() : "MMM", CultureInfo.CurrentCulture) + : DateRange(bucket.FirstDay, last, includeYear); + + case BucketSize.Year: + var yearStart = new DateOnly(bucket.FirstDay.Year, 1, 1); + return IsWholeUnit(bucket, yearStart, yearStart.AddYears(1)) + ? bucket.FirstDay.Year.ToString(CultureInfo.CurrentCulture) + : DateRange(bucket.FirstDay, last, includeYear: true); + + default: + return DateRange(bucket.FirstDay, last, includeYear); + } + } + + /// + /// A change (D-08) as "+12,00 € (+4,5 %)": the absolute difference always, with its sign; the percentage only + /// where it means something, otherwise the words saying it does not apply. when no change + /// can be stated. + /// + /// The change. + /// Formats the signed difference (money, a quantity with its unit). + public static string ChangeText(Change change, Func formatAbsolute) + { + ArgumentNullException.ThrowIfNull(change); + ArgumentNullException.ThrowIfNull(formatAbsolute); + + return change.IsAvailable + ? ChangeAbsolute(change, formatAbsolute) + " (" + ChangePercent(change) + ")" + : Unknown; + } + + /// The signed difference of a change ("+12,00 €", "-3 kWh", "0 kWh"); when unavailable. + public static string ChangeAbsolute(Change change, Func formatAbsolute) + { + ArgumentNullException.ThrowIfNull(change); + ArgumentNullException.ThrowIfNull(formatAbsolute); + + if (change.Absolute is not { } difference) + { + return Unknown; + } + + return change.Direction switch + { + > 0 => "+" + formatAbsolute(difference), + < 0 => formatAbsolute(difference), + _ => formatAbsolute(0), + }; + } + + /// The percentage of a change ("+4,5 %"), the words "percentage not applicable" (D-08), or . + public static string ChangePercent(Change change) + { + ArgumentNullException.ThrowIfNull(change); + + if (!change.IsAvailable) + { + return Unknown; + } + + return change.Percent is { } percent ? Percent(percent) : Strings.Format_PercentNotApplicable; + } + + /// + /// The value as it is shown at : rounded half away from zero (the default formatting + /// rounds half to even), and a plain zero where it rounds to zero — no "-0,00". + /// + private static double Clean(double value, int decimals) + { + if (!double.IsFinite(value) || decimals is < 0 or > 15) + { + return value; + } + + var rounded = Math.Round(value, decimals, MidpointRounding.AwayFromZero); + return rounded == 0 ? 0 : rounded; + } + + private static int AutoDecimals(double value) + { + var size = Math.Abs(value); + return size >= 10 || size == 0 ? 0 : size >= 1 ? 1 : 2; + } + + private static DateOnly LastDayOf(AnalysisBucket bucket) => + bucket.EndDay > bucket.FirstDay ? bucket.EndDay.AddDays(-1) : bucket.FirstDay; + + /// True when the bucket is its whole calendar unit, or the unit up to now (cut short, not clipped by the range). + private static bool IsWholeUnit(AnalysisBucket bucket, DateOnly unitStart, DateOnly unitEnd) => + bucket.FirstDay == unitStart && (bucket.NominalEndDay ?? bucket.EndDay) == unitEnd; + + /// The culture's day-and-month pattern with the abbreviated month: "MMM d" (en), "d. MMM" (de). + private static string DayMonthPattern() => Abbreviate(CultureInfo.CurrentCulture.DateTimeFormat.MonthDayPattern); + + /// The culture's long date without the weekday, abbreviated: "MMM d, yyyy" (en), "d. MMM yyyy" (de). + private static string DayMonthYearPattern() => + Abbreviate(WeekdayPattern().Replace(CultureInfo.CurrentCulture.DateTimeFormat.LongDatePattern, string.Empty).Trim(' ', ',', '.')); + + /// The culture's month-and-year pattern, abbreviated: "MMM yyyy". + private static string YearMonthPattern() => Abbreviate(CultureInfo.CurrentCulture.DateTimeFormat.YearMonthPattern); + + private static string Abbreviate(string pattern) => pattern.Replace("MMMM", "MMM", StringComparison.Ordinal); + + /// The weekday (ddd/dddd) and the separator after it. + [GeneratedRegex(@"d{3,4}[,.]?\s*", RegexOptions.CultureInvariant)] + private static partial Regex WeekdayPattern(); } diff --git a/src/App/InstanceClock.cs b/src/App/InstanceClock.cs new file mode 100644 index 0000000..1fd16a6 --- /dev/null +++ b/src/App/InstanceClock.cs @@ -0,0 +1,21 @@ +using MeterVault.Core.Analysis; +using MeterVault.Infrastructure.Options; +using Microsoft.Extensions.Options; + +namespace MeterVault.App; + +/// +/// "Today" as a page asks for it (D-01): the clock read once, on the date it shows in the instance zone. The UTC date +/// is a different day for the first hours of every local day in zones ahead of UTC (and the last ones behind it), which +/// put the overview a month behind on the 1st. +/// +public sealed class InstanceClock(TimeProvider time, IOptions options) +{ + private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve(options.Value.TimeZone); + + /// The current instant. + public DateTimeOffset Now => time.GetUtcNow(); + + /// The local date of in the instance zone. + public DateOnly Today => PeriodResolver.LocalDate(time.GetUtcNow(), _zone); +} diff --git a/src/App/InstanceCurrency.cs b/src/App/InstanceCurrency.cs new file mode 100644 index 0000000..5ed1030 --- /dev/null +++ b/src/App/InstanceCurrency.cs @@ -0,0 +1,28 @@ +using MeterVault.Infrastructure.Options; +using Microsoft.Extensions.Options; + +namespace MeterVault.App; + +/// +/// The instance currency (D-43, A12: MeterVault__Currency, EUR by default) and its money formatting, for any +/// component to inject: @inject InstanceCurrency Currency@Currency.Format(amount). Every amount the +/// cost engine returns is in this currency ( reads the same +/// setting the same way). +/// +public sealed class InstanceCurrency(IOptions options) +{ + /// The ISO code (EUR), trimmed; EUR when the setting is blank. + public string Code { get; } = string.IsNullOrWhiteSpace(options?.Value.Currency) ? "EUR" : options!.Value.Currency.Trim(); + + /// The symbol shown next to amounts (, $, £, CHF, else the code). + public string Symbol => MeterVault.App.Format.CurrencySymbol(Code); + + /// An amount in the instance currency ("1.234,50 €" for a German reader). + public string Format(double value) => MeterVault.App.Format.Money(value, Code); + + /// An amount, or "—" when it is unknown (never a fabricated zero). + public string Format(double? value) => MeterVault.App.Format.Money(value, Code); + + /// An amount with an explicit sign ("+12,00 €", "−3,50 €") — changes and credits. + public string FormatSigned(double value) => MeterVault.App.Format.MoneySigned(value, Code); +} diff --git a/src/App/Localization/DisplayNames.Analysis.cs b/src/App/Localization/DisplayNames.Analysis.cs new file mode 100644 index 0000000..6c9d909 --- /dev/null +++ b/src/App/Localization/DisplayNames.Analysis.cs @@ -0,0 +1,423 @@ +using MeterVault.App.Analysis; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; + +namespace MeterVault.App.Localization; + +// Wording of the analysis enums (D-47 tokens, bucket and cost states, problems, roles). Every arm resolves an +// Enum__ resource; the fallback returns the identifier, and EnumDisplayNameTests keeps it unreachable. +public static partial class DisplayNames +{ + /// The analysis enums this file words; includes them. + /// A computed property, not a field: it is read while the other part's static fields initialize. + private static IReadOnlyList AnalysisEnums => + [ + typeof(PeriodPreset), + typeof(BucketSize), + typeof(ComparisonKind), + typeof(BucketStatus), + typeof(ValueIssue), + typeof(QuantityKind), + typeof(ResolutionClass), + typeof(TotalsMeasure), + typeof(MeterTotalsClass), + typeof(SeriesBasis), + typeof(VirtualMeterStatus), + typeof(AnalysisProblemKind), + typeof(CostAttentionKind), + typeof(MeterCostRule), + typeof(MeterNotCostedReason), + typeof(CompositionSliceKind), + typeof(LatestPeriodBasis), + typeof(CostStatus), + typeof(BillLineKind), + typeof(BillingBasis), + typeof(Provenance), + typeof(MeterRole), + typeof(TotalsOverride), + typeof(VirtualCostRule), + typeof(FreshnessState), + typeof(ComparisonUnavailableReason), + typeof(AnalysisMetric), + typeof(QueryScopeKind), + typeof(AnalysisQueryNoticeKind), + ]; + + public static string Display(this PeriodPreset value) => value switch + { + PeriodPreset.MonthToDate => Strings.Enum_PeriodPreset_MonthToDate, + PeriodPreset.LastMonth => Strings.Enum_PeriodPreset_LastMonth, + PeriodPreset.YearToDate => Strings.Enum_PeriodPreset_YearToDate, + PeriodPreset.PreviousYear => Strings.Enum_PeriodPreset_PreviousYear, + PeriodPreset.Last12Months => Strings.Enum_PeriodPreset_Last12Months, + PeriodPreset.Last24Months => Strings.Enum_PeriodPreset_Last24Months, + PeriodPreset.AllHistory => Strings.Enum_PeriodPreset_AllHistory, + PeriodPreset.Custom => Strings.Enum_PeriodPreset_Custom, + _ => value.ToString(), + }; + + public static string Display(this BucketSize value) => value switch + { + BucketSize.Auto => Strings.Enum_BucketSize_Auto, + BucketSize.Day => Strings.Enum_BucketSize_Day, + BucketSize.Week => Strings.Enum_BucketSize_Week, + BucketSize.Month => Strings.Enum_BucketSize_Month, + BucketSize.Year => Strings.Enum_BucketSize_Year, + _ => value.ToString(), + }; + + public static string Display(this ComparisonKind value) => value switch + { + ComparisonKind.None => Strings.Enum_ComparisonKind_None, + ComparisonKind.PreviousPeriod => Strings.Enum_ComparisonKind_PreviousPeriod, + ComparisonKind.PreviousYear => Strings.Enum_ComparisonKind_PreviousYear, + ComparisonKind.Year => Strings.Enum_ComparisonKind_Year, + _ => value.ToString(), + }; + + public static string Display(this BucketStatus value) => value switch + { + BucketStatus.Available => Strings.Enum_BucketStatus_Available, + BucketStatus.Partial => Strings.Enum_BucketStatus_Partial, + BucketStatus.Missing => Strings.Enum_BucketStatus_Missing, + BucketStatus.Unresolved => Strings.Enum_BucketStatus_Unresolved, + BucketStatus.Invalid => Strings.Enum_BucketStatus_Invalid, + BucketStatus.Pending => Strings.Enum_BucketStatus_Pending, + _ => value.ToString(), + }; + + public static string Display(this ValueIssue value) => value switch + { + ValueIssue.None => Strings.Enum_ValueIssue_None, + ValueIssue.NoCoverage => Strings.Enum_ValueIssue_NoCoverage, + ValueIssue.PartialCoverage => Strings.Enum_ValueIssue_PartialCoverage, + ValueIssue.CoarseResolution => Strings.Enum_ValueIssue_CoarseResolution, + ValueIssue.OpeningBalance => Strings.Enum_ValueIssue_OpeningBalance, + ValueIssue.RegisterDiscontinuity => Strings.Enum_ValueIssue_RegisterDiscontinuity, + ValueIssue.SampleGap => Strings.Enum_ValueIssue_SampleGap, + ValueIssue.MissingSource => Strings.Enum_ValueIssue_MissingSource, + ValueIssue.NonFinite => Strings.Enum_ValueIssue_NonFinite, + ValueIssue.InvalidDefinition => Strings.Enum_ValueIssue_InvalidDefinition, + ValueIssue.DependencyCycle => Strings.Enum_ValueIssue_DependencyCycle, + ValueIssue.LegacyDefinition => Strings.Enum_ValueIssue_LegacyDefinition, + ValueIssue.NotPriced => Strings.Enum_ValueIssue_NotPriced, + ValueIssue.PriceGap => Strings.Enum_ValueIssue_PriceGap, + ValueIssue.UnitMismatch => Strings.Enum_ValueIssue_UnitMismatch, + ValueIssue.AnalysisPending => Strings.Enum_ValueIssue_AnalysisPending, + ValueIssue.NotYetOccurred => Strings.Enum_ValueIssue_NotYetOccurred, + ValueIssue.RecordedAfterNow => Strings.Enum_ValueIssue_RecordedAfterNow, + _ => value.ToString(), + }; + + public static string Display(this QuantityKind value) => value switch + { + QuantityKind.Consumption => Strings.Enum_QuantityKind_Consumption, + QuantityKind.Generation => Strings.Enum_QuantityKind_Generation, + QuantityKind.Export => Strings.Enum_QuantityKind_Export, + QuantityKind.Runtime => Strings.Enum_QuantityKind_Runtime, + QuantityKind.Net => Strings.Enum_QuantityKind_Net, + QuantityKind.Indicator => Strings.Enum_QuantityKind_Indicator, + QuantityKind.Cost => Strings.Enum_QuantityKind_Cost, + _ => value.ToString(), + }; + + public static string Display(this ResolutionClass value) => value switch + { + ResolutionClass.Hour => Strings.Enum_ResolutionClass_Hour, + ResolutionClass.Day => Strings.Enum_ResolutionClass_Day, + ResolutionClass.Week => Strings.Enum_ResolutionClass_Week, + ResolutionClass.Month => Strings.Enum_ResolutionClass_Month, + ResolutionClass.Coarse => Strings.Enum_ResolutionClass_Coarse, + _ => value.ToString(), + }; + + public static string Display(this TotalsMeasure value) => value switch + { + TotalsMeasure.Use => Strings.Enum_TotalsMeasure_Use, + TotalsMeasure.GridImport => Strings.Enum_TotalsMeasure_GridImport, + TotalsMeasure.Export => Strings.Enum_TotalsMeasure_Export, + TotalsMeasure.Generation => Strings.Enum_TotalsMeasure_Generation, + TotalsMeasure.Runtime => Strings.Enum_TotalsMeasure_Runtime, + _ => value.ToString(), + }; + + public static string Display(this MeterTotalsClass value) => value switch + { + MeterTotalsClass.Use => Strings.Enum_MeterTotalsClass_Use, + MeterTotalsClass.Breakdown => Strings.Enum_MeterTotalsClass_Breakdown, + MeterTotalsClass.GridImport => Strings.Enum_MeterTotalsClass_GridImport, + MeterTotalsClass.Export => Strings.Enum_MeterTotalsClass_Export, + MeterTotalsClass.Generation => Strings.Enum_MeterTotalsClass_Generation, + MeterTotalsClass.Runtime => Strings.Enum_MeterTotalsClass_Runtime, + MeterTotalsClass.AnalysisOnly => Strings.Enum_MeterTotalsClass_AnalysisOnly, + MeterTotalsClass.ExcludedByOverride => Strings.Enum_MeterTotalsClass_ExcludedByOverride, + MeterTotalsClass.IncludedByOverride => Strings.Enum_MeterTotalsClass_IncludedByOverride, + MeterTotalsClass.NotCounted => Strings.Enum_MeterTotalsClass_NotCounted, + _ => value.ToString(), + }; + + public static string Display(this SeriesBasis value) => value switch + { + SeriesBasis.Physical => Strings.Enum_SeriesBasis_Physical, + SeriesBasis.Virtual => Strings.Enum_SeriesBasis_Virtual, + SeriesBasis.LegacyVirtual => Strings.Enum_SeriesBasis_LegacyVirtual, + SeriesBasis.Measure => Strings.Enum_SeriesBasis_Measure, + _ => value.ToString(), + }; + + public static string Display(this VirtualMeterStatus value) => value switch + { + VirtualMeterStatus.Valid => Strings.Enum_VirtualMeterStatus_Valid, + VirtualMeterStatus.Legacy => Strings.Enum_VirtualMeterStatus_Legacy, + VirtualMeterStatus.NeedsConfiguration => Strings.Enum_VirtualMeterStatus_NeedsConfiguration, + VirtualMeterStatus.Malformed => Strings.Enum_VirtualMeterStatus_Malformed, + VirtualMeterStatus.Invalid => Strings.Enum_VirtualMeterStatus_Invalid, + _ => value.ToString(), + }; + + public static string Display(this AnalysisProblemKind value) => value switch + { + AnalysisProblemKind.AnalysisPending => Strings.Enum_AnalysisProblemKind_AnalysisPending, + AnalysisProblemKind.UnknownMeter => Strings.Enum_AnalysisProblemKind_UnknownMeter, + AnalysisProblemKind.InvalidDefinition => Strings.Enum_AnalysisProblemKind_InvalidDefinition, + AnalysisProblemKind.MalformedDefinition => Strings.Enum_AnalysisProblemKind_MalformedDefinition, + AnalysisProblemKind.LegacyDefinition => Strings.Enum_AnalysisProblemKind_LegacyDefinition, + AnalysisProblemKind.LegacyNeedsConfiguration => Strings.Enum_AnalysisProblemKind_LegacyNeedsConfiguration, + AnalysisProblemKind.RecordedAfterNow => Strings.Enum_AnalysisProblemKind_RecordedAfterNow, + AnalysisProblemKind.StaleSource => Strings.Enum_AnalysisProblemKind_StaleSource, + AnalysisProblemKind.TotalsProblem => Strings.Enum_AnalysisProblemKind_TotalsProblem, + AnalysisProblemKind.PossibleOverlap => Strings.Enum_AnalysisProblemKind_PossibleOverlap, + _ => value.ToString(), + }; + + public static string Display(this CostAttentionKind value) => value switch + { + CostAttentionKind.MissingPrice => Strings.Enum_CostAttentionKind_MissingPrice, + CostAttentionKind.UnverifiedTariffUnit => Strings.Enum_CostAttentionKind_UnverifiedTariffUnit, + CostAttentionKind.ManualCostAfterToday => Strings.Enum_CostAttentionKind_ManualCostAfterToday, + CostAttentionKind.ManualCostCurrency => Strings.Enum_CostAttentionKind_ManualCostCurrency, + CostAttentionKind.VirtualNotCosted => Strings.Enum_CostAttentionKind_VirtualNotCosted, + CostAttentionKind.BillingConfiguration => Strings.Enum_CostAttentionKind_BillingConfiguration, + CostAttentionKind.PriceChangeInsideInterval => Strings.Enum_CostAttentionKind_PriceChangeInsideInterval, + CostAttentionKind.BillingBasisGap => Strings.Enum_CostAttentionKind_BillingBasisGap, + CostAttentionKind.CategoryPricesNothing => Strings.Enum_CostAttentionKind_CategoryPricesNothing, + _ => value.ToString(), + }; + + public static string Display(this MeterCostRule value) => value switch + { + MeterCostRule.BillLine => Strings.Enum_MeterCostRule_BillLine, + MeterCostRule.UnitPriceView => Strings.Enum_MeterCostRule_UnitPriceView, + MeterCostRule.FeedInView => Strings.Enum_MeterCostRule_FeedInView, + MeterCostRule.SourceCosts => Strings.Enum_MeterCostRule_SourceCosts, + MeterCostRule.OwnQuantity => Strings.Enum_MeterCostRule_OwnQuantity, + MeterCostRule.None => Strings.Enum_MeterCostRule_None, + _ => value.ToString(), + }; + + public static string Display(this MeterNotCostedReason value) => value switch + { + MeterNotCostedReason.None => Strings.Enum_MeterNotCostedReason_None, + MeterNotCostedReason.Generation => Strings.Enum_MeterNotCostedReason_Generation, + MeterNotCostedReason.Runtime => Strings.Enum_MeterNotCostedReason_Runtime, + MeterNotCostedReason.NoCostRule => Strings.Enum_MeterNotCostedReason_NoCostRule, + MeterNotCostedReason.NotEvaluable => Strings.Enum_MeterNotCostedReason_NotEvaluable, + MeterNotCostedReason.SourcesNotPureSum => Strings.Enum_MeterNotCostedReason_SourcesNotPureSum, + _ => value.ToString(), + }; + + public static string Display(this CompositionSliceKind value) => value switch + { + CompositionSliceKind.Category => Strings.Enum_CompositionSliceKind_Category, + CompositionSliceKind.Uncategorized => Strings.Enum_CompositionSliceKind_Uncategorized, + CompositionSliceKind.StandingCharge => Strings.Enum_CompositionSliceKind_StandingCharge, + _ => value.ToString(), + }; + + public static string Display(this LatestPeriodBasis value) => value switch + { + LatestPeriodBasis.Meters => Strings.Enum_LatestPeriodBasis_Meters, + LatestPeriodBasis.Manual => Strings.Enum_LatestPeriodBasis_Manual, + LatestPeriodBasis.Both => Strings.Enum_LatestPeriodBasis_Both, + _ => value.ToString(), + }; + + public static string Display(this CostStatus value) => value switch + { + CostStatus.Priced => Strings.Enum_CostStatus_Priced, + CostStatus.Partial => Strings.Enum_CostStatus_Partial, + CostStatus.NotPriced => Strings.Enum_CostStatus_NotPriced, + CostStatus.PriceGap => Strings.Enum_CostStatus_PriceGap, + CostStatus.UnitMismatch => Strings.Enum_CostStatus_UnitMismatch, + _ => value.ToString(), + }; + + public static string Display(this BillLineKind value) => value switch + { + BillLineKind.UnitPrice => Strings.Enum_BillLineKind_UnitPrice, + BillLineKind.OwnPrice => Strings.Enum_BillLineKind_OwnPrice, + BillLineKind.FeedIn => Strings.Enum_BillLineKind_FeedIn, + _ => value.ToString(), + }; + + public static string Display(this BillingBasis value) => value switch + { + BillingBasis.None => Strings.Enum_BillingBasis_None, + BillingBasis.GridImport => Strings.Enum_BillingBasis_GridImport, + BillingBasis.Use => Strings.Enum_BillingBasis_Use, + _ => value.ToString(), + }; + + public static string Display(this MeterRole value) => value switch + { + MeterRole.TotalLoad => Strings.Enum_MeterRole_TotalLoad, + MeterRole.GridImport => Strings.Enum_MeterRole_GridImport, + MeterRole.GridExport => Strings.Enum_MeterRole_GridExport, + _ => value.ToString(), + }; + + public static string Display(this TotalsOverride value) => value switch + { + TotalsOverride.Auto => Strings.Enum_TotalsOverride_Auto, + TotalsOverride.Always => Strings.Enum_TotalsOverride_Always, + TotalsOverride.Never => Strings.Enum_TotalsOverride_Never, + _ => value.ToString(), + }; + + public static string Display(this VirtualCostRule value) => value switch + { + VirtualCostRule.None => Strings.Enum_VirtualCostRule_None, + VirtualCostRule.SourceCosts => Strings.Enum_VirtualCostRule_SourceCosts, + VirtualCostRule.OwnQuantity => Strings.Enum_VirtualCostRule_OwnQuantity, + _ => value.ToString(), + }; + + public static string Display(this FreshnessState value) => value switch + { + FreshnessState.NoData => Strings.Enum_FreshnessState_NoData, + FreshnessState.Historical => Strings.Enum_FreshnessState_Historical, + FreshnessState.Live => Strings.Enum_FreshnessState_Live, + FreshnessState.Stale => Strings.Enum_FreshnessState_Stale, + _ => value.ToString(), + }; + + public static string Display(this ComparisonUnavailableReason value) => value switch + { + ComparisonUnavailableReason.None => Strings.Enum_ComparisonUnavailableReason_None, + ComparisonUnavailableReason.NotRequested => Strings.Enum_ComparisonUnavailableReason_NotRequested, + ComparisonUnavailableReason.AllHistory => Strings.Enum_ComparisonUnavailableReason_AllHistory, + ComparisonUnavailableReason.NoCurrentPeriod => Strings.Enum_ComparisonUnavailableReason_NoCurrentPeriod, + ComparisonUnavailableReason.CurrentNotYetOccurred => Strings.Enum_ComparisonUnavailableReason_CurrentNotYetOccurred, + ComparisonUnavailableReason.NotYearAligned => Strings.Enum_ComparisonUnavailableReason_NotYearAligned, + ComparisonUnavailableReason.SameYear => Strings.Enum_ComparisonUnavailableReason_SameYear, + ComparisonUnavailableReason.MissingYear => Strings.Enum_ComparisonUnavailableReason_MissingYear, + ComparisonUnavailableReason.ComparisonNotYetOccurred => Strings.Enum_ComparisonUnavailableReason_ComparisonNotYetOccurred, + ComparisonUnavailableReason.Empty => Strings.Enum_ComparisonUnavailableReason_Empty, + ComparisonUnavailableReason.OutOfRange => Strings.Enum_ComparisonUnavailableReason_OutOfRange, + _ => value.ToString(), + }; + + public static string Display(this AnalysisMetric value) => value switch + { + AnalysisMetric.Consumption => Strings.Enum_AnalysisMetric_Consumption, + AnalysisMetric.Generation => Strings.Enum_AnalysisMetric_Generation, + AnalysisMetric.Export => Strings.Enum_AnalysisMetric_Export, + AnalysisMetric.Runtime => Strings.Enum_AnalysisMetric_Runtime, + AnalysisMetric.Net => Strings.Enum_AnalysisMetric_Net, + AnalysisMetric.Cost => Strings.Enum_AnalysisMetric_Cost, + AnalysisMetric.Balance => Strings.Enum_AnalysisMetric_Balance, + _ => value.ToString(), + }; + + public static string Display(this QueryScopeKind value) => value switch + { + QueryScopeKind.Portfolio => Strings.Enum_QueryScopeKind_Portfolio, + QueryScopeKind.EnergyType => Strings.Enum_QueryScopeKind_EnergyType, + QueryScopeKind.Category => Strings.Enum_QueryScopeKind_Category, + QueryScopeKind.Meter => Strings.Enum_QueryScopeKind_Meter, + QueryScopeKind.Meters => Strings.Enum_QueryScopeKind_Meters, + _ => value.ToString(), + }; + + public static string Display(this AnalysisQueryNoticeKind value) => value switch + { + AnalysisQueryNoticeKind.InvalidPeriod => Strings.Enum_AnalysisQueryNoticeKind_InvalidPeriod, + AnalysisQueryNoticeKind.InvalidRange => Strings.Enum_AnalysisQueryNoticeKind_InvalidRange, + AnalysisQueryNoticeKind.InvalidBucket => Strings.Enum_AnalysisQueryNoticeKind_InvalidBucket, + AnalysisQueryNoticeKind.InvalidComparison => Strings.Enum_AnalysisQueryNoticeKind_InvalidComparison, + AnalysisQueryNoticeKind.InvalidMetric => Strings.Enum_AnalysisQueryNoticeKind_InvalidMetric, + AnalysisQueryNoticeKind.InvalidScope => Strings.Enum_AnalysisQueryNoticeKind_InvalidScope, + AnalysisQueryNoticeKind.InvalidId => Strings.Enum_AnalysisQueryNoticeKind_InvalidId, + AnalysisQueryNoticeKind.TooManyMeters => Strings.Enum_AnalysisQueryNoticeKind_TooManyMeters, + _ => value.ToString(), + }; + + /// + /// The flags a value carries, worded and comma-joined ("Measured, Estimated"); empty for + /// (a missing bucket has no provenance to speak of). + /// + public static string Display(this Provenance value) + { + if (value == Provenance.None) + { + return string.Empty; + } + + var names = new List(3); + if (value.HasFlag(Provenance.Measured)) + { + names.Add(Strings.Enum_Provenance_Measured); + } + + if (value.HasFlag(Provenance.Manual)) + { + names.Add(Strings.Enum_Provenance_Manual); + } + + if (value.HasFlag(Provenance.Imported)) + { + names.Add(Strings.Enum_Provenance_Imported); + } + + if (value.HasFlag(Provenance.Estimated)) + { + names.Add(Strings.Enum_Provenance_Estimated); + } + + if (value.HasFlag(Provenance.Derived)) + { + names.Add(Strings.Enum_Provenance_Derived); + } + + if (value.HasFlag(Provenance.OpeningBalance)) + { + names.Add(Strings.Enum_Provenance_OpeningBalance); + } + + return names.Count > 0 ? string.Join(", ", names) : value.ToString(); + } + + /// A comparison as a toolbar names it: its kind, or for a named year the year itself ("2024"). + public static string Display(this ComparisonRequest value) + { + ArgumentNullException.ThrowIfNull(value); + + return value.Kind == ComparisonKind.Year && value.Year is { } year + ? year.ToString(System.Globalization.CultureInfo.InvariantCulture) + : value.Kind.Display(); + } + + /// What a role means, in one line, for the editor next to the role's name (D-21). + public static string Meaning(this MeterRole value) => value switch + { + MeterRole.TotalLoad => Strings.Enum_MeterRoleMeaning_TotalLoad, + MeterRole.GridImport => Strings.Enum_MeterRoleMeaning_GridImport, + MeterRole.GridExport => Strings.Enum_MeterRoleMeaning_GridExport, + _ => value.ToString(), + }; +} diff --git a/src/App/Localization/DisplayNames.Problems.cs b/src/App/Localization/DisplayNames.Problems.cs new file mode 100644 index 0000000..b948f8b --- /dev/null +++ b/src/App/Localization/DisplayNames.Problems.cs @@ -0,0 +1,63 @@ +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Analysis.Virtual; + +namespace MeterVault.App.Localization; + +// Wording of the configuration problems the analysis reports (D-26, D-22/D-23, D-53): why a virtual meter's calculation +// is invalid, what contradicts itself in the totals configuration, and why two meters may count the same energy. Each +// value is a complete sentence without its closing period, so an attention item can say ": . ." — +// specific rather than "the calculation is invalid". EnumDisplayNameTests pins every value through LocalizedEnums. +public static partial class DisplayNames +{ + /// The problem enums this file words; includes them. + /// A computed property, not a field: it is read while the other part's static fields initialize. + private static IReadOnlyList ProblemEnums => + [ + typeof(VirtualProblemKind), + typeof(TotalsProblemKind), + typeof(OverlapHintKind), + ]; + + public static string Display(this VirtualProblemKind value) => value switch + { + VirtualProblemKind.Syntax => Strings.Enum_VirtualProblemKind_Syntax, + VirtualProblemKind.NoReferences => Strings.Enum_VirtualProblemKind_NoReferences, + VirtualProblemKind.UnknownMeter => Strings.Enum_VirtualProblemKind_UnknownMeter, + VirtualProblemKind.SelfReference => Strings.Enum_VirtualProblemKind_SelfReference, + VirtualProblemKind.DependencyCycle => Strings.Enum_VirtualProblemKind_DependencyCycle, + VirtualProblemKind.SourceInvalid => Strings.Enum_VirtualProblemKind_SourceInvalid, + VirtualProblemKind.SourceNotConfigured => Strings.Enum_VirtualProblemKind_SourceNotConfigured, + VirtualProblemKind.UnitMismatch => Strings.Enum_VirtualProblemKind_UnitMismatch, + VirtualProblemKind.KindMismatch => Strings.Enum_VirtualProblemKind_KindMismatch, + VirtualProblemKind.ProductNeedsIndicator => Strings.Enum_VirtualProblemKind_ProductNeedsIndicator, + VirtualProblemKind.IndicatorNeedsUnit => Strings.Enum_VirtualProblemKind_IndicatorNeedsUnit, + VirtualProblemKind.ResultKindRequired => Strings.Enum_VirtualProblemKind_ResultKindRequired, + VirtualProblemKind.ResultKindUnsupported => Strings.Enum_VirtualProblemKind_ResultKindUnsupported, + VirtualProblemKind.ResultKindMismatch => Strings.Enum_VirtualProblemKind_ResultKindMismatch, + VirtualProblemKind.ResultUnitMismatch => Strings.Enum_VirtualProblemKind_ResultUnitMismatch, + VirtualProblemKind.CostRuleNeedsPureSum => Strings.Enum_VirtualProblemKind_CostRuleNeedsPureSum, + VirtualProblemKind.CostRuleNeedsLinear => Strings.Enum_VirtualProblemKind_CostRuleNeedsLinear, + VirtualProblemKind.CostRuleNotForIndicator => Strings.Enum_VirtualProblemKind_CostRuleNotForIndicator, + VirtualProblemKind.IndicatorSourceNeedsIndicator => Strings.Enum_VirtualProblemKind_IndicatorSourceNeedsIndicator, + _ => value.ToString(), + }; + + public static string Display(this TotalsProblemKind value) => value switch + { + TotalsProblemKind.DuplicateRole => Strings.Enum_TotalsProblemKind_DuplicateRole, + TotalsProblemKind.RoleNotApplicable => Strings.Enum_TotalsProblemKind_RoleNotApplicable, + TotalsProblemKind.OverrideRefused => Strings.Enum_TotalsProblemKind_OverrideRefused, + TotalsProblemKind.ContainmentCycle => Strings.Enum_TotalsProblemKind_ContainmentCycle, + TotalsProblemKind.TotalLoadIsContained => Strings.Enum_TotalsProblemKind_TotalLoadIsContained, + TotalsProblemKind.SeparateBillingUnitMismatch => Strings.Enum_TotalsProblemKind_SeparateBillingUnitMismatch, + TotalsProblemKind.UnusedMeterPrice => Strings.Enum_TotalsProblemKind_UnusedMeterPrice, + _ => value.ToString(), + }; + + public static string Display(this OverlapHintKind value) => value switch + { + OverlapHintKind.GridImportNotLinkedToTotalLoad => Strings.Enum_OverlapHintKind_GridImportNotLinkedToTotalLoad, + OverlapHintKind.NotLinkedBelowTotalLoad => Strings.Enum_OverlapHintKind_NotLinkedBelowTotalLoad, + _ => value.ToString(), + }; +} diff --git a/src/App/Localization/DisplayNames.cs b/src/App/Localization/DisplayNames.cs index 0037ed3..e08e652 100644 --- a/src/App/Localization/DisplayNames.cs +++ b/src/App/Localization/DisplayNames.cs @@ -23,7 +23,7 @@ namespace MeterVault.App.Localization; /// what stops that safety net from quietly becoming the shipping behaviour. /// /// -public static class DisplayNames +public static partial class DisplayNames { /// The enums this class is responsible for; the resource-coverage test walks this list. public static IReadOnlyList LocalizedEnums { get; } = @@ -41,6 +41,8 @@ public static class DisplayNames typeof(EndpointType), typeof(MappingRole), typeof(UpdateAvailability), + .. AnalysisEnums, + .. ProblemEnums, ]; public static string Display(this MeterMode value) => value switch diff --git a/src/App/Localization/MeterVaultMudLocalizer.cs b/src/App/Localization/MeterVaultMudLocalizer.cs new file mode 100644 index 0000000..81760c5 --- /dev/null +++ b/src/App/Localization/MeterVaultMudLocalizer.cs @@ -0,0 +1,59 @@ +using System.Globalization; +using Microsoft.Extensions.Localization; +using MudBlazor; + +namespace MeterVault.App.Localization; + +/// +/// MudBlazor's own words — the accessible names and messages its components carry ("Toggle …" on a nav group, a dialog's +/// Close, a field's Clear, a date picker's months) — in the app's languages (brief §8: EN/DE strings). MudBlazor ships +/// English only; for any other culture it asks this localizer and falls back to its English for a key not mapped here. +/// +/// +/// Every key maps to a typed accessor, so both resx files carry it and StringResourceTests +/// keeps it translated; the English values are MudBlazor's own, so the English UI reads as before. Registered after +/// AddMudServices() as MudLocalizer. +/// +public sealed class MeterVaultMudLocalizer : MudLocalizer +{ + /// + public override LocalizedString this[string key] => Translate(key, []); + + /// + public override LocalizedString this[string key, params object[] arguments] => Translate(key, arguments); + + /// The app's wording of a MudBlazor key in the current UI culture; null for a key it leaves to MudBlazor. + public static string? Lookup(string key) => key switch + { + "MudNavGroup_ToggleExpand" => Strings.Mud_NavGroupToggleExpand, + "MudDialog_Close" => Strings.Mud_Close, + "MudAlert_Close" => Strings.Mud_Close, + "MudChip_Close" => Strings.Mud_Close, + "MudSnackbar_Close" => Strings.Mud_Close, + "MudInput_Clear" => Strings.Mud_InputClear, + "MudInput_Increment" => Strings.Mud_InputIncrement, + "MudInput_Decrement" => Strings.Mud_InputDecrement, + "MudBaseDatePicker_Open" => Strings.Mud_PickerOpen, + "MudTimePicker_Open" => Strings.Mud_PickerOpen, + "MudBaseDatePicker_NextMonth" => Strings.Mud_DatePickerNextMonth, + "MudBaseDatePicker_PrevMonth" => Strings.Mud_DatePickerPreviousMonth, + "MudBaseDatePicker_NextYear" => Strings.Mud_DatePickerNextYear, + "MudBaseDatePicker_PrevYear" => Strings.Mud_DatePickerPreviousYear, + "MudTabs_ScrollLeft" => Strings.Mud_TabsScrollLeft, + "MudTabs_ScrollRight" => Strings.Mud_TabsScrollRight, + "MudTable_Loading" => Strings.Mud_Loading, + "Converter_InvalidNumber" => Strings.Mud_InvalidNumber, + "Converter_InvalidDateTime" => Strings.Mud_InvalidDateTime, + _ => null, + }; + + private static LocalizedString Translate(string key, object[] arguments) + { + if (Lookup(key) is not { } value) + { + return new LocalizedString(key, key, resourceNotFound: true); + } + + return new LocalizedString(key, arguments.Length == 0 ? value : string.Format(CultureInfo.CurrentCulture, value, arguments)); + } +} diff --git a/src/App/Localization/Strings.de.resx b/src/App/Localization/Strings.de.resx index 0df7d64..b57fdeb 100644 --- a/src/App/Localization/Strings.de.resx +++ b/src/App/Localization/Strings.de.resx @@ -66,6 +66,213 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Diagramm: {0}. Die Tabelle enthält dieselben Werte. + + + Kosten nicht verfügbar: {0}. Legen Sie einen Tarif an, um Kosten zu sehen. + + + – Kein Wert (keine Daten, nicht bewertet oder nicht berechenbar) – die Tabelle nennt den Grund. + + + * Teilweise, geschätzt oder nicht vollständig bewertet – die Tabelle nennt den Grund. + + + Auflösung der Daten: {0} – diese Intervalle sind feiner und können sie nicht zeigen. + + + Die Daten sind hier gröber als diese Intervalle, die sie deshalb nicht zeigen können. + + + Veränderung + + + Vergleich + + + Kosten + + + Details zu {0} + + + Werte nach Zeitraum + + + Diese Berechnung lässt sich nicht aufsummieren: Die Summe wird über den ganzen Zeitraum berechnet, nicht aus den Zeilen addiert. + + + Zeitraum + + + Preisabdeckung + + + Gesamt + + + Tarif anlegen + + + Alle Energiearten + + + {0}: Die Auswertung wird vorbereitet. + + + {0} ist nicht verknüpft und wird als Teil von {1} gezählt. + + + {0}: Die Einheit von {1} ist ab {2} nicht verwendbar – ein Grundpreis wird pro Tag, Monat oder Jahr angegeben. + + + {0} war von {1} bis {2} nicht in Betrieb, während Verbrauch gemessen wurde. Die Rechnung dieser Monate ist nicht verfügbar. + + + {0}: Die Abrechnungseinstellung passt nicht und wurde umgangen. + + + {0}: Die Abrechnungseinstellung passt nicht und wurde umgangen. {1}. + + + Kategorie #{0} + + + {0}: {1} tragen nichts zu den Kosten dieser Kategorie bei – eine Kategorie bepreist gemessenen Verbrauch und Einspeisung, keine berechneten Ansichten, Erzeugung oder Betriebsstunden. Nehmen Sie stattdessen die gemessenen Zähler auf. + + + Quelle prüfen + + + Berechnung bestätigen + + + {0}: {1} fehlt ab {2} – die Gutschrift ist nicht enthalten. + + + Berechnung bearbeiten + + + Kategorien bearbeiten + + + Zähler bearbeiten + + + Energieart #{0} + + + Tarif korrigieren + + + {0} und {1} sind nicht verknüpft und zählen möglicherweise dieselbe Energie doppelt. + + + {0}: Die Berechnung ist ungültig, daher können keine Werte angezeigt werden. + + + {0}: Die Berechnung ist ungültig, daher können keine Werte angezeigt werden. {1}. + + + {0} wird bis zur Bestätigung der Berechnung als Summe der verknüpften Zähler berechnet. + + + {0}: Die Berechnung muss noch eingerichtet werden. + + + {0}: Die gespeicherte Berechnung kann nicht gelesen werden. + + + Zähler verwalten + + + Manuelle Kosten mit Datum nach heute werden noch nicht gezählt ({0}). + + + Manuelle Kosten in anderer Währung wurden wie eingegeben gezählt ({0}). + + + Zähler #{0} + + + Tarife öffnen + + + {0} wird möglicherweise doppelt gezählt. + + + {0}: Der Preis ändert sich innerhalb eines Ableseintervalls zwischen {1} und {2}. Diese Kosten lassen sich nicht auf Monate aufteilen und sind nicht verfügbar. + + + {0}: {1} ist ab {2} in einer anderen Währung angegeben, als diese Instanz verwendet. + + + {0}: {1} fehlt ab {2}. + + + {0}: {1} nicht hinterlegt. + + + {0}: {1} passt ab {2} nicht zur Einheit des Zählers. + + + {0}: In der Zukunft datierte Werte ({1}) werden noch nicht mitgezählt. + + + {0}: In der Zukunft datierte Werte werden noch nicht mitgezählt. + + + Berechnung einrichten + + + Fehler: + + + Hinweis: + + + Warnung: + + + Alle anzeigen ({0}) + + + Weniger anzeigen + + + Datensätze anzeigen + + + {0}: Die Live-Quelle liefert keine Werte mehr. + + + Zu beachten + + + {0}: Die Summenkonfiguration ist widersprüchlich. + + + {0}: Die Summenkonfiguration widerspricht sich. {1}. + + + {0} und {1}: Die Summenkonfiguration ist widersprüchlich. + + + {0} und {1}: Die Summenkonfiguration widerspricht sich. {2}. + + + {0} wurde nicht gefunden. + + + Die Einheit eines Tarifs konnte nicht geprüft werden; er wurde wie eingegeben angewendet. + + + {0} zählt zur Abrechnung, hat aber keine Kostenregel und fehlt deshalb. + + + Navigationspfad + Hinzufügen @@ -123,8 +330,17 @@ Sortierreihenfolge - - Kosten + + {0} weniger + + + {0} mehr + + + Keine Veränderung + + + Kein Vergleich Aktionen @@ -135,9 +351,6 @@ Abbrechen - - Kosten (Zeitraum) - Währung @@ -189,24 +402,12 @@ Vorschau - - Zeitraum - - - Gesamter Zeitraum - - - Letzte 12 Monate - - - Letzte 24 Monate - - - Letzte 5 Jahre - Zählerstände: + + Erneut versuchen + Speichern @@ -225,9 +426,6 @@ Ziel - - Dieses Jahr - Typ @@ -237,6 +435,21 @@ Wert + + Veränderung über den gemeinsam abgedeckten Zeitraum: {0} und {1}. + + + Nicht vergleichbar: Die Zeiträume haben keine gemeinsam abgedeckten Tage, daher werden nur die Werte gezeigt. + + + {0} im Vergleich zu {1} + + + Kein Vergleich: {0}. + + + (mit Lücken) + Konnektor hinzufügen @@ -399,77 +612,197 @@ ja - - Stand {0} + + Füllstand am {0} - Brennerstunden + Brennerlaufzeit - - Verbrauch je Monat + + Peilung vom {0}, seither keine Lieferung; der Verbrauch seither ist nicht abgezogen. + + + Peilung vom {0} plus seither geliefert: {1}; der Verbrauch seither ist nicht abgezogen. + + + Kosten + + + Zum Tarif des Tanks + + + Geliefert - Lieferungen ({0}) + Lieferungen: {0} - - Verbrauchsrate + + Lieferungen in diesem Zeitraum + + + Füllstand, Lieferungen, Verbrauch und Kosten jedes Tanks: was jetzt bekannt ist und was im gewählten Zeitraum geschah. + + + am {0} + + + am {0} · abgelesen {1} + + + Geschätzt jetzt (inkl. Lieferungen seither) + + + Braucht einen erfassten Füllstand. + + + Füllgrad - {0} % von {1} {2} + {0} von {1} + + + Nicht innerhalb von 100 Jahren Voraussichtlich leer + + Braucht erfasste Füllstände. + + + Keine Prognose + + + Zwischen den Peilungen wurde nichts verbraucht. + + + Dieses Datum ist vorbei – erfassen Sie einen Füllstand. + + + Die Peilungen des letzten Jahres umfassen weniger als {0} Tage. + + + Die letzte Peilung ist {0} Tage alt; eine Prognose braucht eine aus den letzten {1} Tagen. + + + Letzte Peilung + - Keine Lieferungen erfasst. + Keine Lieferungen in diesem Zeitraum. - - Keine Zähler für Vorräte gefunden. Legen Sie einen Zähler mit dem Modus + + Keine - - , oder laden Sie die Referenzdaten über + + Noch kein Füllstand erfasst. - - an und richten Sie seinen Tank ein unter + + Bis dahin wurde kein Füllstand erfasst. - - Vorräte + + Tanks sind Zähler mit dem Modus „{0}“. Legen Sie einen in der Zählerliste an und richten Sie in seinen Einstellungen den Tank ein, oder importieren Sie seine Daten aus einer CSV-Datei. + + + Noch kein Tank + + + Erfassen Sie regelmäßig Füllstände: Der Verbrauch ist zwischen zwei Peilungen bekannt. + + + Aus der letzten Peilung, unabhängig vom gewählten Zeitraum + + + Aktuell - ({0} {1}/Tag) + {0} pro Tag + + + Gewählter Zeitraum + + + Verbrauchsrate + + + Verbrauch ÷ Brennerlaufzeit in diesem Zeitraum + + + Feste Rate aus den Tankeinstellungen + + + Braucht einen Betriebsstundenzähler derselben Energieart. + + + Der Betriebsstundenzähler zählt keine Stunden. Lieferung erfassen - - Füllstand + + Füllstand erfassen + + + Von {0} + + + Laufzeit ({0}) + + + Tank einrichten Für {0} ist noch kein Tank eingerichtet — Füllstand, Füllgrad und Prognose brauchen Fassungsvermögen und Peilstab-Kalibrierung des Tanks. - - Verbrauch ({0}) + + Verbrauch je Intervall ({0}) - - Verbrauch (Zeitraum) + + Verbrauch + + + Aus Peilungen und Lieferungen + + + berechnet + + + Kosten: {0} + + + Faktor + + + Formel + + + Diese Berechnung hat noch keine Quellen. + + + nicht linear + + + Anteil am Ergebnis + + + Quellzähler + + + Quellen + + + Verwendet + + + „Verwendet“ zählt nur die Tage, die alle Quellen abdecken; der Anteil ist Faktor × verwendete Menge. + + + Eigener Wert Kategorie - - Dieses Jahr - - - Vorjahr - - - Letzter Monat mit Daten - - Kosten werden je Kostenkategorie ausgewiesen, und es gibt noch keine: - - - Dieses Jahr noch keine Kosten. Sie erscheinen, sobald ein Zähler in einer Kostenkategorie Verbrauch bei gültigem Tarif erfasst. + Die Aufteilung gliedert die Rechnung nach Kostenkategorien, und es gibt noch keine: Noch kein Zähler gehört zu einer Kostenkategorie. Kategorien beim Bearbeiten eines Zählers wählen oder je Kategorie zuordnen: @@ -480,14 +813,29 @@ Noch keine Tarife — Verbrauch braucht einen Preis, bevor er Kosten hat: - - Dieser Monat + + Grundpreis — {0} - - Was mehr / weniger kostet (Jahr vs. Vorjahr) + + Grundpreis — global - - Größte Kostenanteile (dieses Jahr) + + Ohne Kategorie + + + Daten liegen vom {0} bis zum {1} vor. + + + Zu den neuesten Daten + + + Keine Daten für diesen Zeitraum + + + Noch keine Daten + + + Dieser Zeitraum hat noch nicht begonnen. Energieart hinzufügen @@ -513,6 +861,9 @@ Energieart löschen + + Hier legen Sie fest, welche Energie- und Versorgungsarten Sie erfassen: Name, Einheit, Symbol und Standardmodus. Die Auswertung einer Energieart öffnen Sie im Menü unter „Energiearten“. + Anzeigename @@ -537,11 +888,101 @@ Schlüssel, Anzeigename und Basiseinheit sind erforderlich. - - Verbrauch + + Energiearten verwalten - - Nachgelagerte Zähler + + Verbindung hinzufügen + + + Verbinden + + + {0} — {1} + + + Zähler wählen + + + Dieser Zeitraum + + + Im Diagramm + + + Von + + + Nach + + + {0} → {1} verbunden. + + + Die Verbindung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut. + + + {0} fließt dann in {1}. + + + {0} → {1} entfernt. + + + Verbindungen + + + Noch keine Verbindungen. + + + Eine Verbindung führt von einem Zähler zu einem, der einen Teil seiner Energie misst (Haus → Auto), oder von einer Quelle zu dem, was sie versorgt (Netz → Haus). Verbindungen zeichnen den Energiefluss und machen einen Teil zu einer Aufschlüsselung, die nie zusätzlich addiert wird. Was ein berechneter Zähler berechnet, ändern sie nie. + + + Für eine Verbindung braucht diese Energieart mindestens zwei Zähler. + + + Verbindungen — {0} + + + {0} ({1}) + + + Einspeisevergütung: {0} + + + Die Rechnung gehört zur Energieart als Ganzes. Was ein einzelner Zähler nach seiner eigenen Regel kostet, zeigt seine Zählerseite. + + + Gezählt: {0} + + + Datenabdeckung + + + Was diese Energieart im gewählten Zeitraum verbraucht, geliefert und gekostet hat. Ihre Definition bearbeiten Sie unter Konfiguration. + + + Fertig + + + Eingang einer berechneten Summe + + + Geschätzt, begrenzt auf die Messung von {0} + + + Geschätzter Anteil (auf die vorgelagerten Zähler verteilt) + + + Gemessener Teil + + + Von keinem nachgelagerten Zähler gemessen + + + Berechnung bearbeiten + + + Definition bearbeiten Energie @@ -550,34 +991,400 @@ Energiefluss - Wohin der Fluss der obersten Ebene geht. Pfeilstärke ∝ Menge; „Sonstige“ ist der nicht erfasste Rest. + Wohin die Energie in diesem Zeitraum floss, in {0}: Die Breite eines Bandes ist seine Menge, „Sonstige“ ist, was kein nachgelagerter Zähler erfasst. - - Fluss: {0} + + fließt in - - Noch keine Zählerkette konfiguriert. Unter + + Der Energiefluss als Tabelle - - → einen Unterzähler bearbeiten und dessen + + Von - - festlegen, um zu zeigen, wie sich der Fluss des Hauptzählers aufteilt (z. B. Haupt → Auto, Pool, Sonstiges). + + Der Zähler, der das Ganze misst, oder die Quelle - - vorgelagerte Zähler + + Wie diese Zähler zählen - - Für diese Zähler ist im gewählten Zeitraum noch kein Verbrauch erfasst. + + Gezeichnet - - Für diese Energieart gibt es noch keine Zähler. Legen Sie welche an unter + + Größte Veränderungen je Zähler - - oder laden Sie Referenzdaten über + + Jede Veränderung wird über die Tage gemessen, die beide Zeiträume abdecken. - - Gesamtdurchsatz + + Legende + + + Gestrichelter Rand: Eingang einer berechneten Summe + + + Gepunkteter Rand: geschätzter Anteil + + + Gefüllt: gemessen + + + Grau: darunter nicht gemessen + + + Verbindungen verwalten + + + inkl. manueller Kosten {0} + + + {0} ({1}) + + + Was jeder Zähler in diesem Zeitraum gemessen hat und wie er in den Summen zählt. + + + {0} rechnet in seiner Formel mit {1}; diese Verbindung zeichnet das nur ein. + + + {0} von {1} Zählern angezeigt. Die übrigen vergleichen Sie unter: + + + Kein Zähler hat in beiden Zeiträumen Daten über dieselben Tage. + + + Wählen Sie einen Vergleich, um zu sehen, welche Zähler sich am stärksten verändert haben. + + + Diese Zähler sind noch nicht verbunden, daher gibt es keinen Energiefluss zu zeichnen. Verbindungen sind optional: Summen und Verlauf funktionieren auch ohne sie. + + + Noch kein Zähler dieser Energieart zählt in einer Summe. Wie jeder eingerichtet ist, sehen Sie unter + + + Zu dieser Energieart gehören noch keine Zähler. + + + Kein Zähler dieser Energieart zeigt „{0}“. + + + Legen Sie einen Zähler mit dieser Energieart an oder importieren Sie die Referenzdaten. + + + Nicht gezeichnet: eine Berechnung, die keine einfache Summe ist + + + Nicht gezeichnet: in diesem Zeitraum nichts gemessen + + + Nicht gezeichnet: gemessen in {0} + + + Diese Energieart gibt es nicht (mehr). + + + Für „{0}“ hat diese Energieart keine Summe; siehe einzelne Zähler. + + + Verlauf öffnen + + + {0} (andere Energieart) + + + Diese Zähler sind bereits so verbunden. + + + {0} wird aus seinen Verbindungen berechnet, bis seine Berechnung bestätigt ist; sie lassen sich daher hier nicht ändern. Legen Sie stattdessen seine Berechnung fest. + + + Das ergäbe einen Kreis: {0}. + + + Diese Verbindung gibt es nicht mehr. + + + Beide Zähler müssen zur selben Energieart gehören. + + + Ein Zähler kann nicht in sich selbst fließen. + + + Einen dieser Zähler gibt es nicht mehr. + + + Verbindung {0} → {1} entfernen + + + inkl. Grundgebühren {0} + + + Fluss + + + Verlauf + + + Zähler ({0}) + + + Übersicht + + + Nach + + + Der Zähler, der einen Teil davon misst oder davon versorgt wird + + + Trend + + + Trend: {0} + + + Verlaufsansicht + + + Einzelne Zähler + + + Jeder Zähler für sich. Sie können sich überschneiden, also nicht addieren: Unten steht, wie jeder zählt. + + + Summe + + + Die Summen der Energieart: Jeder Zähler zählt einmal, ein Teil nie zusätzlich zu seinem Ganzen. + + + Nicht verbunden; gilt als Teil von {0}, der den gesamten Verbrauch misst. + + + Teil von {0}: wird gezeigt, aber nie zusätzlich addiert. + + + Darüber misst kein Zähler, daher zählt er zum Gesamtverbrauch. + + + Oberhalb des Gesamtverbrauchszählers {0} verbunden; wird erst gezählt, wenn das geklärt ist. + + + Wird stattdessen über {0} gezählt. + + + {0} hat dieselbe Rolle; wird erst gezählt, wenn das geklärt ist. + + + Was ins Netz zurückging; zählt nie als Verbrauch. + + + Hier erzeugt; zählt zur Erzeugung. + + + Was das Netz geliefert hat: steht neben dem Gesamtverbrauch, wird aber nie dazugezählt. + + + Per Einstellung immer gezählt; ersetzt {0}. + + + Per Einstellung immer gezählt. + + + Per Einstellung nie gezählt. + + + Betriebszeit, wird für sich gezählt. + + + Hat die Rolle Gesamtverbrauch und bildet damit allein den Gesamtverbrauch. + + + Berechnet aus {0}, die bereits gezählt werden: eine Auswertungsansicht, die nie zu den Summen addiert wird. + + + Eine berechnete Auswertungsansicht, die nie zu den Summen addiert wird. + + + Füllstand + + + Verbrauch + + + Kosten + + + Einspeisung + + + Erzeugung + + + Saldo + + + Laufzeit + + + Auswertung wird vorbereitet + + + Ungültige Berechnung + + + Berechnung bestätigen + + + Berechnung muss eingerichtet werden + + + Berechnung nicht lesbar + + + Mögliche Doppelzählung + + + In der Zukunft datiert + + + Quelle liefert nicht mehr + + + Widersprüchliche Summenkonfiguration + + + Zähler nicht gefunden + + + Die Auflösung im Link wurde nicht erkannt; der Standard wird verwendet. + + + Der Vergleich im Link wurde nicht erkannt; der Standard wird verwendet. + + + Einige IDs im Link sind ungültig und wurden ignoriert. + + + Die Kennzahl im Link wurde nicht erkannt; der Standard wird angezeigt. + + + Der Zeitraum im Link wurde nicht erkannt; der Standardzeitraum wird angezeigt. + + + Die Daten im Link ergeben keinen gültigen Zeitraum; der Standardzeitraum wird angezeigt. + + + Die Auswahl im Link wurde nicht erkannt; der Standard wird angezeigt. + + + Es wurden zu viele Zähler gewählt; nur die ersten werden angezeigt. + + + Einspeisevergütung + + + Eigener Zählerpreis + + + Arbeitspreis + + + Abrechnung nach Netzbezug + + + Keine Abrechnung + + + Abrechnung nach Gesamtverbrauch + + + Automatisch + + + Täglich + + + Monatlich + + + Wöchentlich + + + Jährlich + + + Vollständig + + + Nicht berechenbar + + + Keine Daten + + + Teilweise + + + Wird vorbereitet + + + Nur gröber aufgelöst + + + Kein Vergleich + + + Vorperiode + + + Vorjahreszeitraum + + + Ausgewähltes Jahr + + + Vor dem gesamten Verlauf gibt es nichts zum Vergleichen + + + Der Vergleichszeitraum liegt in der Zukunft + + + Der Zeitraum hat noch nicht begonnen + + + Der Vergleichszeitraum hat keine passenden Tage + + + Kein Jahr gewählt + + + Es gibt keine Daten zum Vergleichen + + + Vergleichbar + + + Kein Vergleich gewählt + + + Ein Jahresvergleich braucht ein Kalenderjahr + + + Der Vergleich liegt außerhalb des unterstützten Zeitraums + + + Das ist dasselbe Jahr + + + Kategorie + + + Grundpreis + + + Ohne Kategorie Verbrauch @@ -585,12 +1392,75 @@ Erzeugung + + Netzzähler nicht in Betrieb + + + Abrechnungseinstellung prüfen + + + Kategoriemitglieder ohne Kosten + + + Manuelle Kosten mit künftigem Datum + + + Manuelle Kosten in anderer Währung + + + Fehlender Preis + + + Preisänderung innerhalb eines Ableseintervalls + + + Tarifeinheit nicht geprüft + + + Virtueller Zähler in der Abrechnung ohne Kostenregel + + + Nicht bewertet (kein Tarif) + + + Teilweise bewertet + + + Nicht verfügbar (Tariflücke) + + + Bewertet + + + Nicht verfügbar (Einheit) + Home Assistant MQTT-Broker + + Historisch (importiert oder manuell) + + + Live + + + Noch keine Daten + + + Veraltet – Quelle liefert nicht + + + Zählerdaten und manuelle Kosten + + + Manuelle Kosten + + + Zählerdaten + Lieferung @@ -606,6 +1476,24 @@ Füllstand + + Sein Anteil an der Rechnung + + + Einspeisevergütung (nicht abgerechnet) + + + Nicht bewertet + + + Eigene Menge zum Arbeitspreis + + + Summe der Kosten der Quellen + + + Eigene Menge zum Arbeitspreis (nicht abgerechnet) + Korrektur @@ -645,6 +1533,156 @@ Virtuell + + Erzeugung wird nie abgerechnet + + + Die Berechnung hat keine Kostenregel + + + Bewertet + + + Die Berechnung kann nicht ausgewertet werden + + + Betriebszeit wird nicht abgerechnet + + + Eine Quellberechnung ist keine reine Summe, daher lassen sich die Kosten der Quellen nicht addieren + + + Misst, was ins Netz eingespeist wird; wird vergütet und nie als Verbrauch gezählt. + + + Misst, was aus dem Netz bezogen wird; danach rechnet der Versorger ab. + + + Misst alles, was vor Ort verbraucht wird; daraus ergibt sich der Gesamtverbrauch der Energieart. + + + Netzeinspeisung + + + Netzbezug + + + Gesamtverbrauch + + + Nur Auswertung + + + Aufschlüsselung eines gezählten Zählers + + + Per Einstellung ausgeschlossen + + + Zählt zur Einspeisung + + + Zählt zur Erzeugung + + + Zählt zum Netzbezug + + + Per Einstellung einbezogen + + + Nicht gezählt (Konfigurationskonflikt) + + + Zählt zur Laufzeit + + + Zählt zum Gesamtverbrauch + + + Netzbezug und Gesamtverbrauchszähler sind nicht verknüpft und zählen möglicherweise dieselbe Energie doppelt + + + Der Zähler ist nicht verknüpft und wird als Teil des Gesamtverbrauchs gezählt + + + Gesamter Verlauf + + + Eigener Zeitraum + + + Letzte 12 Monate + + + Letzte 24 Monate + + + Letzter Monat + + + Dieser Monat bis heute + + + Vorjahr + + + Dieses Jahr bis heute + + + Berechnet + + + Geschätzt + + + Importiert + + + Manuell + + + Gemessen + + + Anfangsbestand + + + Verbrauch + + + Kosten + + + Einspeisung + + + Erzeugung + + + Kennzahl + + + Saldo + + + Laufzeit + + + Kostenkategorie + + + Energieart + + + Zähler + + + Zählervergleich + + + Alle Energiearten + Anomalie @@ -672,6 +1710,33 @@ Gemessen + + Gröber als monatlich + + + Täglich + + + Stündlich oder feiner + + + Monatlich + + + Wöchentlich + + + Berechnet (Summe verknüpfter Zähler, zu bestätigen) + + + Summe + + + Physischer Zähler + + + Berechnet (Formel) + Home Assistant @@ -738,6 +1803,51 @@ Zähler + + Einspeisung + + + Erzeugung + + + Netzbezug + + + Laufzeit + + + Gesamtverbrauch + + + Immer mitzählen + + + Automatisch (nach Topologie) + + + Nie mitzählen + + + Die Zählerverknüpfungen bilden eine Schleife + + + Zwei Zähler haben gleichzeitig dieselbe Rolle; der zuerst angelegte behält sie + + + „Immer mitzählen“ wurde nicht angewendet, weil dieselbe Energie schon gezählt wird + + + Die Rolle des Zählers passt nicht zu seiner Messart und wird ignoriert + + + Der Zähler hat einen eigenen Preis, aber seine Einheit passt nicht zu dem Zähler, aus dem er herausgerechnet würde, daher bleibt er in dessen Abrechnung + + + Der Gesamtverbrauchszähler hängt unter einem anderen Zähler + + + Der Zähler hat einen eigenen Preis, den keine Abrechnung nutzt – verknüpfen Sie ihn unter dem Zähler, der ihn versorgt + Zulässig @@ -747,6 +1857,141 @@ auf dieser Installation nicht unterstützt + + Die Auswertung wird vorbereitet + + + Die Daten sind für diese Auflösung zu grob (z. B. Monatswerte in der Tagesansicht) + + + Die Berechnung verweist auf sich selbst + + + Die Berechnung ist ungültig + + + Bis zur Bestätigung als Summe der verknüpften Zähler berechnet + + + Einem Quellzähler fehlen hier Daten + + + Für diesen Zeitraum liegen keine Daten vor + + + Die Formel ergibt keinen endlichen Wert (z. B. Division durch null) + + + Kein Hinweis + + + Kein Tarif hinterlegt + + + Noch nicht eingetreten + + + Enthält einen ersten Zählerstand mit unbekanntem Beginn + + + Die Daten decken nur einen Teil des Zeitraums ab + + + Für diese Monate gilt kein Tarif + + + Einige Werte sind in der Zukunft datiert und noch nicht mitgezählt + + + Der Zählerstand springt ohne erfassten Wechsel oder Reset + + + Zu lange keine Messwerte + + + Die Tarifeinheit passt nicht zum Zähler + + + Keine Kosten (nur Auswertung) + + + Eigene Menge bepreisen + + + Summe der Kosten der Quellen + + + Ungültige Definition + + + Übernommen – bitte bestätigen + + + Definition nicht lesbar + + + Einrichtung nötig + + + Gültig + + + Die Kostenregel „eigene Menge zum Arbeitspreis“ braucht eine lineare Formel + + + Die Kostenregel „Summe der Kosten der Quellen“ braucht eine reine Summe + + + Eine Kennzahl kann keine Kostenregel haben + + + Die Berechnung hängt über andere berechnete Zähler von sich selbst ab + + + Eine Kennzahl braucht eine Ergebniseinheit + + + Eine Quelle ist eine Kennzahl, daher muss auch das Ergebnis eine sein + + + Die Formel addiert oder subtrahiert verschiedene Mengenarten, das Ergebnis ist aber nicht als Saldo festgelegt + + + Die Formel verweist auf keinen Zähler + + + Zähler zu multiplizieren oder zu teilen erfordert die Ergebnisart „Kennzahl“ + + + Die Ergebnisart widerspricht den Quellzählern + + + Die Ergebnisart muss gewählt werden, weil die Quellzähler sie nicht vorgeben + + + Ein berechneter Zähler kann diese Ergebnisart nicht haben + + + Die Ergebniseinheit widerspricht den Quellzählern + + + Die Formel verweist auf den Zähler selbst + + + Ein berechneter Quellzähler hat eine ungültige Berechnung + + + Ein berechneter Quellzähler hat noch keine Berechnung + + + Die Formel ist nicht lesbar + + + Die Formel addiert oder subtrahiert Zähler mit unterschiedlichen Einheiten + + + Die Formel verweist auf einen Zähler, den es nicht gibt + Für lokales Debugging die Umgebung {0} aktivieren: dazu die Umgebungsvariable {1} auf {0} setzen und die App neu starten. @@ -774,9 +2019,21 @@ Anfrage-ID: + + Menge: {0} + + + Für einige Zähler ist kein Tarif hinterlegt + + + Eine Tarifeinheit konnte nicht geprüft werden + Sonstige ({0}) + + keine Prozentangabe möglich + Zeile {0}: Zählerstand von Zähler {1} ist von {2} auf {3} gefallen; ein Zählerwechsel wurde angelegt (bitte prüfen). @@ -1023,12 +2280,12 @@ Ein unbehandelter Fehler ist aufgetreten. - - etwa gleich - Ersten Zählerstand erfassen + + Tarif für diesen Zähler anlegen + Zählerstand erfassen @@ -1038,6 +2295,18 @@ Quelle hinzufügen + + Nach jetzt + + + Nach jetzt datiert: hier gelistet, aber noch in keiner Zahl enthalten. + + + Alle Ereignisse in diesem Zeitraum + + + Menge ({0}) + Weiteren Konnektor einrichten @@ -1053,8 +2322,35 @@ (Anfangs-Zählerstand {0}) + + Probleme + + + Die gespeicherte Berechnung ist ungültig, daher können keine Werte angezeigt werden. Beheben Sie die Probleme unten im Editor. + + + Noch ist keine Berechnung gespeichert: Der Zähler wird als Summe der verknüpften Zähler berechnet, bis Sie das im Editor bestätigen. + + + Die gespeicherte Berechnung ist nicht lesbar. Öffnen Sie den Editor und speichern Sie die Berechnung erneut. + + + Dieser Zähler hat noch keine Berechnung, und seine Verknüpfungen ergeben keine. Richten Sie sie im Editor ein. + + + Die Berechnung ist gültig. Ihre Werte werden bei jedem Abruf aus den Quellzählern berechnet — sie hat keine eigenen Zählerstände. + + + Diese Berechnung lässt sich nicht auswerten ({0}), daher können keine Werte angezeigt werden. + + + Flussverbindungen zwischen Zählern beschreiben nur die Topologie; sie ändern diese Berechnung nie. + - {0} {1} seit dem letzten Stand + {0} {1} seit dem vorigen Stand + + + {0} von {1} Komponente @@ -1074,8 +2370,20 @@ „{0}“ ist ein {1}-Konnektor; eine {2}-Quelle benötigt {3}. - - Kosten dieses Jahr + + Kosten von {0} + + + Regel: {0} + + + Die gespeicherte Regel „{0}“ passt nicht zu dieser Formel, daher wird der Zähler nicht bepreist. + + + Kostenregel + + + Kosten (einmal einrichten; jede Quelle wählt ihn danach nur noch aus). @@ -1083,8 +2391,11 @@ einen anlegen + + Daten vorhanden + - Unter dem letzten Zählerstand ({0} {1}) bei einem Zählwerk, das nur vorwärts zählt — der Wert würde abgelehnt. Wurde der Zähler gewechselt oder das Zählwerk zurückgesetzt, erfassen Sie das zuerst — der eingegebene Wert bleibt erhalten. + Unter dem vorigen Zählerstand ({0} {1}) bei einem Zählwerk, das nur vorwärts zählt — der Wert würde abgelehnt. Wurde der Zähler gewechselt oder das Zählwerk zurückgesetzt, erfassen Sie das zuerst — der eingegebene Wert bleibt erhalten. {0} vom {1} löschen? Der dabei erfasste Anfangsstand des neuen Zählwerks wird mit entfernt und der Verbrauch neu berechnet. @@ -1110,6 +2421,18 @@ Quelle löschen + + Wählen Sie einen Balken oder eine Tabellenzeile, um genauer hinzusehen — bis zu den Datensätzen, wo die Daten nicht feiner sind. + + + Wählen Sie einen Balken oder eine Tabellenzeile, um genauer hinzusehen. + + + Berechnung bearbeiten + + + Konnektor bearbeiten + Zähler bearbeiten @@ -1119,8 +2442,8 @@ aktivieren - - Energiefluss „{0}“ öffnen + + Verlauf von {0} öffnen Wert eingeben @@ -1143,6 +2466,9 @@ Flags + + Aktualität + Von @@ -1161,14 +2487,14 @@ Stammt aus einem Import — zum Entfernen den Import zurücknehmen. + + In diesem Zeitraum + Art - - {0} diesen Monat - - - Vormonat {0} {1} + + Letzter Stand oder letztes Ereignis: {0} Letzter Zählerstand {0} {1} am {2}. @@ -1176,11 +2502,20 @@ Letzter Wert - - · {0} im Vorjahr + + Die verknüpften Zähler unterscheiden sich in Einheit oder Art, daher ist ihre Summe nicht eindeutig - - Gesamt seit Beginn + + Er hat keine verknüpften Zähler seiner Energieart. + + + Ein verknüpfter Zähler misst etwas, das nicht aufsummiert wird + + + Ein verknüpfter berechneter Zähler muss zuerst eingerichtet werden + + + Ein verknüpfter Zähler existiert nicht Ortszeit in {0}. @@ -1194,14 +2529,20 @@ Neue Quelle + + Erfassen Sie einen ersten Zählerstand oder verbinden Sie eine Live-Quelle. Schon ein einzelner Stand zählt gegen den Anfangs-Zählerstand. + + + Erfassen Sie einen Füllstand; Lieferungen und spätere Füllstände ergeben dann den Verbrauch. + + + Seine Quellzähler haben noch keine Daten — Werte erscheinen hier, sobald sie welche haben. + nein - - noch kein Vergleich - - keine Änderung seit dem letzten Stand + keine Änderung seit dem vorigen Stand Noch kein {0}-Konnektor — @@ -1209,15 +2550,36 @@ Noch kein normalisierter Verbrauch. + + Noch keine Daten + Noch keine Ereignisse erfasst. + + Keine Ereignisse in diesem Zeitraum. + + + Keine Formel gespeichert. + + + Keine normalisierten Daten in diesem Zeitraum. + Noch keine Rohdaten. + + Keine Zählerstände in diesem Zeitraum. + Noch keine Zählerstände — vorbelegt mit dem Anfangs-Zählerstand ({0} {1}). + + Die Berechnung nennt noch keine Zähler. + + + Der aus Zählerständen und Ereignissen abgeleitete Verbrauch bzw. die Erzeugung, in {0} — daraus entstehen Diagramme, Summen und Kosten. Abgeleitet und reproduzierbar: wird neu berechnet, sobald sich ein Stand oder Ereignis ändert. + Diesem Zähler ist keine Quelle zugeordnet. Eine Quelle hinzufügen, um Daten von MQTT/Tasmota oder Home Assistant zu erfassen. @@ -1227,6 +2589,21 @@ Keine passenden Tarife. + + Aus Betriebsstunden mit der festen Rate des Tanks umgerechnet — eine Schätzung, kein gemessenes Volumen. + + + Die Rate gilt nicht pro Stunde, daher sind die Mengen nicht in ihrer Mengeneinheit; geben Sie der Quelle eine Skalierung auf eine Rate pro Stunde. + + + Die Einheit ist keine Rate, daher werden die Werte als Rate pro Stunde gelesen. + + + Die feste Rate gilt pro Stunde, das Zählwerk zählt aber keine Stunden, daher weicht das Volumen um den Faktor des Zählwerks ab. + + + Die Berechnung erklärt kein Ergebnis, daher wird bis zum Speichern Verbrauch in der Einheit des Zählers angenommen. + Zähler #{0} nicht gefunden. @@ -1236,6 +2613,27 @@ Offset + + Der erste Zählerstand wurde ab unbekanntem Beginn gegen den Anfangs-Zählerstand ({0} {1}) gerechnet; diese Menge ist daher ein Anfangsbestand: Sie wird gezeigt, aber nicht verglichen oder hochgerechnet. Ein Einbaudatum gibt ihr einen Beginn. + + + Zu den Tarifen + + + Neuere + + + Neueste + + + Ältere + + + Zeilen {0}–{1} von {2} + + + Seiten + Für diese {1}-Quelle einen {0}-Konnektor auswählen. @@ -1260,18 +2658,24 @@ „{0}“ ist {1}, benötigt {2} - - ≈ {0} {1} bis Monatsende - - - ≈ {0} {1} im Gesamtjahr - Qualität + + Datenqualität und Abdeckung + + + Misst + + + {0} in {1} + Zählerstand gelöscht — Verbrauch neu berechnet. + + Der Zählerstand konnte nicht gespeichert werden. Bitte erneut versuchen. + Zählerstand ({0}) @@ -1284,14 +2688,14 @@ Zählerstand gespeichert: {0} {1}. - - Zählerstände + + Rohdaten sind das Prüfprotokoll: jeder Wert genau so, wie er ankam, in der Einheit des Zählwerks, und nie geändert, um eine Zahl zu korrigieren. Alle werden aufbewahrt — eine Aufbewahrungsfrist für Rohdaten wird nicht durchgesetzt. Die Auswertung liest den daraus abgeleiteten normalisierten Verlauf, nicht diese Liste. - - Die letzten {0} normalisierten Differenzen. + + Über berechnete Quellen liest sie: - - Die letzten {0} (Rohdaten, unveränderlich und revisionssicher). Zeiten in {1}. + + {0}: {2} mit Datum nach jetzt ({3}) wird noch nicht gezählt. Zeilen: {1}. Ereignis erfassen @@ -1305,11 +2709,11 @@ Füllstand erfassen - - Details zum Zählwerk + + Zähler in der Formel - - Zählwerk (von → bis) + + Zählwerk: {0} ({1}) → {2} ({3}) {4} Für diesen Zeitpunkt gibt es bereits einen Zählerstand — beim Speichern wird sein Wert ersetzt. @@ -1317,9 +2721,24 @@ Ersetzt den beim Zählerwechsel erfassten Anfangsstand des neuen Zählers — der Verbrauch über den Wechsel bleibt korrekt. + + Auflösung + + + Tage und Wochen zeigen keine Werte; ein Monat öffnet seine Datensätze. + + + Tage und Wochen zeigen keine Werte, weil die Quelldaten nicht feiner sind. + + + Ergebnis + stillgelegt + + stillgelegt seit {0} + Zählerstand speichern @@ -1335,9 +2754,21 @@ Seriennr. {0} + + Einbaudatum festlegen + Tank einrichten + + Alle Daten anzeigen + + + Berechnung anzeigen + + + Datensätze anzeigen + Diese Uhrzeit gab es in {0} nicht — die Uhren wurden vorgestellt. Bitte eine andere Zeit wählen. @@ -1347,33 +2778,57 @@ Quelle gespeichert. + + Live-Quellen, die Zählerstände für diesen Zähler liefern, jeweils über einen Konnektor. + + + Quellzähler + Quellentyp - Unter letztem Stand — Zählerwechsel? + Unter vorigem Stand — Zählerwechsel? - - Verbrauch ({0}) + + Auswertung + + + Berechnung - Ereignisse ({0}) + Ereignisse + + + Normalisierte Daten - Zählerstände ({0}) + Zählerstände - Quellen ({0}) + Quellen - Tarife ({0}) + Tarife Der Verbrauch eines Tanks ergibt sich aus Füllständen und Lieferungen, die als Ereignisse erfasst werden — ein hier eingetragener Zählerstand hätte keine Wirkung. + + {0} {1} {2} ({3}) + + + gilt jetzt + offen + + Ein Preis gilt, bis der nächste seiner Art beginnt. Je Komponente geht ein eigener Preis des Zählers dem der Energieart vor, dieser einem globalen. + + + Die Preise, die für diesen Zähler gelten können. + Zeit @@ -1383,33 +2838,27 @@ Zeitpfad (optional, z. B. Time) + + Zeiten in {0}. + Bis MQTT-Topic (z. B. tele/plug1/SENSOR) + + unbekannter Zähler + + + Wert ({0}) + Wertart Wertpfad (z. B. ENERGY.Total; leer = einfacher Zahlenwert) - - Ein virtueller Zähler hat keine eigenen Zählerstände — tragen Sie die Zählerstände an den Zählern ein, die er aufsummiert. - - - Virtueller Zähler — er hat keine eigenen Zählerstände; sein Wert ist die Summe der verknüpften vorgelagerten Zähler. - - - In der Flussansicht ansehen. - - - ggü. Vormonat - - - {0} ggü. {1} im Vorjahr - ja @@ -1566,6 +3015,9 @@ Verbrauch seit dem letzten Füllstand: {0} {1} + + Ein berechneter Zähler trägt keine Kosten zu einer Kategorie bei: Kategorien bepreisen die gemessenen Zähler, die er liest. Seine eigenen Kosten folgen seiner Kostenregel. + {0} von {1} angezeigt — zum Eingrenzen weitertippen. @@ -1581,9 +3033,21 @@ Zähler hinzufügen + + Alle Energiearten + Während der Dialog offen war, hat sich etwas geändert (ein Zähler oder eine Kategorie wurde gelöscht). Nichts wurde gespeichert — bitte prüfen und erneut speichern. + + Zählt als + + + Daten + + + Im Zeitraum + Kostenkategorien @@ -1593,15 +3057,30 @@ Über seine Energieart bereits enthalten in: {0} + + {0} Zähler + + + {0} von {1} Zählern + „{0}“ wirklich löschen? Das kann nicht rückgängig gemacht werden. „{0}“ wirklich löschen? Dabei werden auch {1} Zählerstände und {2} Verbrauchswerte gelöscht. Das kann nicht rückgängig gemacht werden. + + {0} löschen + Zähler löschen + + Diese virtuellen Zähler rechnen damit und stimmen erst wieder, wenn Sie sie anpassen: {0}. + + + Alle Zähler mit dem, was sie im gewählten Zeitraum gemessen haben. Öffnen Sie einen für Auswertung, Zählerstände und Einstellungen. + {0} bearbeiten @@ -1611,9 +3090,18 @@ Noch keine Zähler. Legen Sie einen an oder laden Sie über + + {0}, Daten bis {1} + Anfangs-Zählerstand + + Eingebaut am + + + Optional. Der erste Zählerstand zählt ab diesem Datum; ohne Datum ist unbekannt, seit wann diese erste Menge angefallen ist. + in der Einheit dieses Zählers (z. B. kW für kWh, L/h für L): eine Quelle, die W oder L/min meldet, braucht dafür einen Skalierungsfaktor. @@ -1638,20 +3126,23 @@ Neuer Zähler - - nein - Noch keine Kostenkategorien, daher erscheinen die Kosten dieses Zählers nicht im Dashboard. Anlegen unter Kein Zähler passt zu „{0}“. - - PV-Rolle (optional) + + Energieart öffnen - - Hauszähler als total_load und Netzzähler als grid_import markieren, um Eigenverbrauch, Autarkie und Ersparnis auf der Solar-Seite freizuschalten. + + {0}: {1} + + + Zählerstand erfassen + + + Füllstand erfassen Verbrauchsrelevante Einstellungen geändert — der Verbrauch wird beim Speichern neu berechnet. @@ -1662,9 +3153,33 @@ Name, Energieart und Einheit sind erforderlich. + + Außer Betrieb + Wurde der physische Zähler getauscht? Erfassen Sie stattdessen einen Zählerwechsel: Historie, Quellen, Flussverknüpfungen und Kostenkategorien bleiben lückenlos an diesem Zähler. Stilllegen nur, wenn der Zähler endgültig wegfällt. + + Außer Betrieb seit + + + Optional. Der Zähler zählt nur bis zu diesem Datum in Summen und Abrechnung und behält für diese Zeit seine Rolle. + + + Das Datum der Außerbetriebnahme darf nicht vor dem Einbaudatum liegen. + + + Rolle in der Energieart (optional) + + + {0} hat diese Rolle derzeit; beim Speichern geht sie auf diesen Zähler über. + + + Bestimmt, was Summen und Abrechnung dieser Energieart zählen. Jede Rolle gehört zu genau einem Zähler in Betrieb. + + + Rolle von {0} übernommen. + — keine — @@ -1674,9 +3189,6 @@ Seriennummer (optional) - - Quellen - Fassungsvermögen @@ -1711,19 +3223,70 @@ Unterzähler von (vorgelagerte Zähler) - Virtueller „Summen“-Zähler — er hat keine eigenen Zählerstände. In der Flussansicht entspricht er der Summe der unten gewählten vorgelagerten Zähler (z. B. Summe Solar = Solar 1 + Solar 2). + Ein virtueller Zähler hat keine eigenen Zählerstände: Er wird aus anderen Zählern berechnet, und seine Auswertung ergibt sich aus deren Daten. - - ja + + Details anzeigen - - Verwaltung + + Schließen + + + Nächster Monat {0} + + + Nächstes Jahr {0} + + + Vorheriger Monat {0} + + + Vorheriges Jahr {0} + + + Leeren + + + Verringern + + + Erhöhen + + + Kein gültiges Datum + + + Keine gültige Zahl + + + Wird geladen… + + + {0} ein- oder ausklappen + + + Öffnen + + + Reiter nach links blättern + + + Reiter nach rechts blättern + + + Analyse + + + Konfiguration Konnektoren - Öl / Vorräte + Tanks & Vorräte + + + Noch kein Tank eingerichtet Kostenkategorien @@ -1731,39 +3294,69 @@ Energiearten + + Energiearten konnten nicht geladen werden. + - Import + Datenimport Zähler + + Noch keine angelegt + Übersicht - - Zähler & Daten - - - Energie - Einstellungen Solar / PV + + Noch kein Erzeugungszähler + + + Spezialansichten + Tarife - - Trends - Der gesuchte Inhalt existiert leider nicht. Nicht gefunden + + Keine Kosten – Mitglieder nicht abgerechnet + + + Ihre Mitglieder sind berechnete Ansichten, Erzeugung oder Betriebsstunden, die eine Kategorie nicht bepreist. + + + Dieser Bereich konnte nicht geladen werden. + + + Aktualisierung fehlgeschlagen. Die angezeigten Werte gehören zur vorherigen Auswahl. + + + Erneut prüfen + + + MeterVault baut diesen Verlauf nach einem Update neu auf. Es geht nichts verloren; die Werte erscheinen hier, sobald das erledigt ist. + + + Auswertung wird vorbereitet + + + Hochrechnung (linear aus {0} Tagen) + + + Hochrechnung (linear aus {0} Tagen): ≈ {1} + Die Sitzung wurde vom Server pausiert. @@ -1791,15 +3384,33 @@ Bitte erneut versuchen oder die Seite neu laden. + + Wird aktualisiert … + Flussdiagramm + + berechnet + + + geschätzt + Kein Fluss in diesem Zeitraum. Zugriff & Datenerfassung + + Auswertungsdaten + + + Diagramme und Summen lesen Verdichtungen, die aus dem Verbrauch in der eingestellten Zeitzone gebildet werden. Ein Zähler aus einer älteren Revision oder einer anderen Zeitzone erscheint als „in Vorbereitung“, bis er beim nächsten Start neu aufgebaut ist. + + + Der Stand der Auswertungsdaten konnte nicht gelesen werden. + geschlossen (401) @@ -1842,6 +3453,24 @@ Gebietsschema & Zeit + + Zähler mit aktuellen Auswertungsdaten + + + {0} von {1} + + + {0} in Vorbereitung + + + keine + + + Verbrauch berechnet mit + + + Zeitzone der gespeicherten Tage und Monate + aus @@ -1851,21 +3480,72 @@ Aufbewahrung der Rohdaten - - {0} Tage + + Warten auf Neuaufbau beim nächsten Start + + + Nicht aktiv + + + Rohdaten werden unbegrenzt aufbewahrt (eingestellt: {0} Tage). Jede Neuberechnung baut einen Zähler aus seinen Zählerständen neu auf; alte Stände zu löschen würde diesen Teil seiner Historie löschen. Reverse-Proxy vertrauen + + noch nicht aufgebaut + + + Revision {0} (aktuell: {1}; Neuaufbau beim nächsten Start) + + + Revision {0} + Referenzdaten beim Start anlegen Zeitzone + + Berechnete Zähler + + + {0} – weicht von der eingestellten Zeitzone ab; Neuaufbau beim nächsten Start + Autarkie + + Anteil des Verbrauchs, den die Solaranlage deckt + + + Braucht Eigenverbrauch und Gesamtverbrauch – siehe unten. + + + {0} minus {1} + + + Gemessen + + + {0} plus {1} + + + Nicht berechenbar: Die Zähler haben verschiedene Einheiten ({0}). + + + Erzeugung, Eigenverbrauch und Einspeisung Ihrer Solaranlage und was sie eingespart hat, für den gewählten Zeitraum. + + + Einspeisung + + + Einspeisevergütung: {0} + + + Braucht einen Zähler für die Netzeinspeisung oder Zähler für Gesamtverbrauch und Netzbezug – siehe unten. + Erzeugung @@ -1875,35 +3555,83 @@ Erzeugung je Zähler - - Netzbezug {0} kWh + + Kein Erzeugungszähler zählt zur Summe – siehe die Zähler unten. - - Keine Erzeugungszähler gefunden. Legen Sie einen Zähler mit dem Modus + + Nicht addiert (andere Einheiten): {0} - - an oder laden Sie die Referenzdaten über + + Netzbezug {0} - - unter + + Ja + + + Zählt zur Summe + + + Nein + + + Nein (berechnet) + + + Erfassen Sie Zählerstände der Erzeugungszähler oder importieren Sie sie aus einer CSV-Datei. + + + Solarzahlen kommen von Zählern mit dem Modus „{0}“. Legen Sie einen in der Zählerliste an oder importieren Sie seine Zählerstände aus einer CSV-Datei. + + + Noch kein Erzeugungszähler + + + Alle Zähler von {0} + + + Weisen Sie einem dieser Zähler die Rolle in seinen Einstellungen zu: + + + {0}: noch kein Zähler + + + Grundlage: + + + Damit wird die Einspeisung gemessen statt berechnet und mit dem Einspeisetarif vergütet. + + + Damit lässt sich der Eigenverbrauch berechnen und zum Netzpreis bewerten. + + + Zusammen mit einem Netzbezugszähler ergibt er Eigenverbrauch, Autarkie und Ersparnis. Ersparnis + + Eigenverbrauch zum Netzpreis von {0} + + + Braucht Eigenverbrauch und für den Preis einen Zähler für Netzbezug oder Gesamtverbrauch – siehe unten. + Eigenverbrauch + + Braucht Zähler für Gesamtverbrauch und Netzbezug oder einen Zähler für die Netzeinspeisung – siehe unten. + + + Solarzahlen vervollständigen + - {0} % der Erzeugung + {0} der Erzeugung - - Setzen Sie die PV-Rolle Ihres Hauszählers auf + + Solarzahlen im Detail - - und die Ihres Netzzählers auf - - - , um Eigenverbrauch, Autarkie und Ersparnis freizuschalten — im Zählereditor unter + + Verbrauch {0} Tarif hinzufügen @@ -1917,21 +3645,54 @@ Tarif löschen + + Preise nach Geltungsbereich und Zeitraum. Der Preis eines Zählers geht dem seiner Energieart vor, dieser einem globalen; jeder Monat wird mit dem am 15. gültigen Tarif berechnet. + Tarif bearbeiten Noch keine Tarife vorhanden. Neu anlegen oder Referenzdaten laden unter + + Hier gilt noch kein Tarif. + + + Nur globale Tarife. + + + Tarife, die {0} bepreisen können: eigene, die seiner Energieart und globale. + + + Tarife für {0}: die der Energieart, ihrer Zähler und globale. + Neuer Tarif + + Noch nicht angewendet + + + Bonus-, Rabatt- und Steuertarife werden gespeichert, aber noch nicht auf die Kosten angewendet. + Notizen (optional) offen + + Tag + + + Monat + + + Quartal + + + Jahr + Global @@ -1944,12 +3705,54 @@ Energieart: {0} + + Alle Tarife anzeigen + Einheit und „Gültig ab“ sind erforderlich. + + Bitte zuerst die Einheit korrigieren: Sie passt nicht zu dem, was dieser Tarif bepreisen würde. + + + Die Einheit ist in {0} angegeben, abgerechnet wird aber in {1}. + + + Passt zu {0} ({1}). + Einheit (z. B. EUR/kWh, EUR/m3, EUR/month) + + Passt nicht zu {0} (gemessen in {1}). + + + In diesem Bereich wird noch nichts abgerechnet oder vergütet, daher lässt sich die Menge nicht prüfen. + + + Gelesen als {0} pro {1}, verteilt auf die Tage dieses Zeitraums. + + + Gelesen als {0} pro {1}. + + + Diese Einheit ist nicht lesbar und lässt sich daher nicht prüfen. Sie würde unverändert angewendet, mit einem Hinweis bei den Kosten. + + + Diese Einheit ist nicht lesbar; der Grundpreis würde pro Monat angesetzt, mit einem Hinweis bei den Kosten. + + + „{0}“ lässt sich nicht auf Tage verteilen; bitte Tag, Monat, Quartal oder Jahr verwenden. + + + Lässt sich nicht gegen {0} ({1}) prüfen; der Tarif würde mit Hinweis angewendet. + + + Ein Grundpreis ist eine Währung pro Tag, Monat, Quartal oder Jahr, z. B. EUR/Monat. + + + Ein Arbeitspreis oder eine Einspeisevergütung ist eine Währung pro Menge, z. B. EUR/kWh, ct/kWh oder EUR/100 L. + Gültig ab @@ -1959,20 +3762,71 @@ Gültig bis (leer = unbefristet) - - Monatliche Kosten + + Ein Preis von 0 macht diesen Zeitraum kostenlos. - - Anwenden + + Bitte einen Wert eingeben. - - Letzte 48 Monate + + Übernehmen - - Kostenverlauf + + Automatisch ({0}) - - Gesamt im Zeitraum: {0} + + Intervall + + + Vergleichen mit + + + CSV exportieren + + + Von + + + Tage nach heute werden noch nicht gezählt. + + + Wählen Sie ein Startdatum, das nicht nach dem Enddatum liegt. + + + Zeitraum und Darstellung + + + Anzeigen + + + Zeitraum + + + Details zum Zeitraum + + + Es werden die Einträge des ganzen Zeitraums gezeigt, auch nach jetzt datierte (markiert). + + + Zurücksetzen + + + Datumsangaben in der Zeitzone {0}. + + + Bis + + + Das Intervall „{0}“ bräuchte {1} Punkte; höchstens {2} lassen sich darstellen. + + + (zu viele Punkte) + + + Werte bis heute, {0} Uhr. + + + „{0}“ verwenden Dabei wird der aktuelle Quellcode geladen, neu gebaut und der Dienst neu gestartet. Das dauert einige Minuten, in denen MeterVault nicht erreichbar ist. Zählerstände sind nicht betroffen — die Erfassung läuft nach dem Neustart weiter. @@ -2010,4 +3864,625 @@ Update gestartet. Der Dienst startet neu, sobald der Build fertig ist — das dauert meist einige Minuten. + + {0} analysieren + + + Mindestens ein Zähler muss ausgewählt bleiben. + + + berechnet + + + {0} (berechnet) + + + Kalenderjahr + + + Jeder Zähler dieser Kategorie wird einzeln gezeigt. Die Werte werden nicht addiert, weil sich Zähler überschneiden können – ein Unterzähler ist Teil des Zählers darüber. + + + Wählen Sie ein gröberes Intervall, um die Werte zu sehen. + + + Die ersten {0} vergleichen + + + Diese Zähler vergleichen + + + Bei mehr als {0} Reihen stehen die Vergleichswerte in der Tabelle statt im Diagramm. + + + Bewertet als: {0} + + + Gezählt: {0} + + + Verbrauch, Erzeugung und Kosten im Zeitverlauf untersuchen – für alles, eine Energieart, eine Kostenkategorie oder mehrere Zähler nebeneinander. + + + {0} in {1}: {2} + + + Zähler (bis zu {0}) + + + Für diese Auswahl wurde noch nichts erfasst. Zählerstände werden auf der Seite eines Zählers erfasst, Tabellen unter + + + Keine Kosten: {0} + + + „{0}“ gibt es für diese Auswahl nicht; gezeigt wird „{1}“. + + + „{0}“ gibt es für diese Auswahl nicht. + + + Einige Zähler aus dem Link gibt es nicht mehr; sie wurden weggelassen. + + + Für {0} nicht gezeigt: {1} – diese Zähler messen etwas anderes. Wählen Sie deren Messgröße, um sie zu vergleichen. + + + Nicht gezeigt: {0} – diese Zähler haben keine eigenen Kosten. + + + Energieart öffnen + + + Zähler öffnen + + + Diese Kategorie überschneidet sich mit anderen oder bepreist, was die Rechnung nicht enthält – sie ist eine Sicht auf die Rechnung, kein Teil davon. + + + Einspeisevergütung {0} + + + Manuelle Kosten {0} + + + Grundgebühren {0} + + + Verbrauch {0} + + + Die Zähler von {0} messen Verschiedenes und lassen sich daher nicht als eine Menge zeigen: + + + {0} hat {1} Zähler; höchstens {2} lassen sich nebeneinander zeigen. Die Kosten lassen sich als Ganzes analysieren. + + + {0} hat keine Zähler, die Kosten werden von Hand erfasst. Die Kategorie lässt sich daher nur nach Kosten analysieren. + + + Worauf dieser Link verweist, gibt es nicht mehr. + + + {0} (stillgelegt) + + + Was analysiert wird + + + Analysieren + + + Alle Energiearten zeigen + + + Kosten zeigen + + + {0} je Zeitraum + + + Werte je Zeitraum + + + Höchstens {0} Zähler lassen sich gleichzeitig vergleichen. Entfernen Sie einen, bevor Sie einen weiteren hinzufügen. + + + Gesamtkosten + + + Gesamtverbrauch und Netzbezug stehen nebeneinander: Sie messen Verschiedenes und werden nie addiert. + + + ein anderer Zähler + + + Automatisch: {0} + + + Berechnung + + + Die Zähler konnten nicht geladen werden, daher lässt sich die Berechnung nicht bearbeiten. Bitte den Dialog schließen und erneut versuchen. + + + Erzeugung wird nie abgerechnet, ihre Quellen kosten also nichts. + + + Eine Kennzahl wird nie bepreist. + + + {0} ist keine einfache Summe. + + + Nur eine Formel ohne Konstanten, Produkte oder Verhältnisse lässt sich als Menge bepreisen. + + + Nur eine einfache Summe von Zählern addiert die Kosten ihrer Quellen. + + + Kosten + + + Die Berechnung hat keine eigenen Kosten; sie dient nur der Auswertung. + + + Bepreist die berechnete Menge mit dem Tarif dieses Zählers, seiner Energieart oder dem globalen. + + + Die gewählte Kostenregel passt nicht mehr zur Berechnung und wurde auf automatisch zurückgesetzt. + + + Addiert, was die Quellen zu ihren eigenen Tarifen kosten, ohne Grundpreise. + + + {0} ist nicht möglich: {1} + + + Formel + + + Die Formel ist leer. + + + „{0}“ ist keine Zahl; Dezimalzahlen mit Punkt schreiben, z. B. 0.5. + + + „{0}“ ist keine gültige Zählernummer. + + + Die Klammer an Position {0} wird nicht geschlossen. + + + Klammern sind tiefer als {0} Ebenen verschachtelt. + + + Die Formel ist länger als {0} Zeichen. + + + „{0}“ ist in einer Formel nicht erlaubt (Position {1}). + + + Die Formel endet, wo ein Zähler oder eine Zahl erwartet wird. + + + „{0}“ gehört nicht an Position {1}. + + + „{0}“ ist kein Zähler; Zähler mit m und ihrer Nummer ansprechen, z. B. m12. + + + z. B. m4 + m5 oder (m1 - m3) * 0.5 + + + Bedeutet: + + + Eine Kennzahl wird nur für sich gezeigt: Sie geht nie in Summen ein und wird nicht bepreist. + + + Zähler einfügen + + + Die Verknüpfungen drehen sich im Kreis: {0}. + + + Die sich ergebende Summe ist nicht gültig. + + + Die verknüpften Zähler messen Unterschiedliches ({0}). + + + Die verknüpften Zähler messen in unterschiedlichen Einheiten ({0}). + + + Kein Zähler seiner Energieart ist mit ihm verknüpft. + + + {0} lässt sich nicht addieren. + + + {0} hat selbst keine Berechnung. + + + Ein verknüpfter Zähler existiert nicht mehr. + + + Die verknüpften Zähler messen {0}; dafür muss das Ergebnis ausdrücklich gewählt werden. + + + Dieser Zähler hat noch keine eigene Berechnung. Seine Verknüpfungen ergeben {0}; unten prüfen und zum Bestätigen speichern. + + + Außerhalb dieses Zeitraums zählt der Zähler in Summen, Abrechnung und Berechnungen als bekannte Null, nicht als fehlende Daten. + + + Die gespeicherte Berechnung ließ sich nicht vollständig lesen. Das Lesbare wird angezeigt, damit sie repariert werden kann. + + + Ausgangszähler + + + gemischt + + + Die Zähler messen Unterschiedliches (zum Beispiel Netzbezug und Einspeisung): Das Ergebnis auf Saldo stellen. + + + Formel + + + Einen Zähler mit m und seiner Nummer ansprechen (m4) und mit + - * /, Klammern und Zahlen mit Dezimalpunkt (0.5) verknüpfen. + + + Differenz + + + Beginnt bei einem Zähler und zieht die anderen ab. Angeboten werden nur Zähler in derselben Einheit. + + + Summe + + + Addiert die gewählten Zähler. Angeboten werden nur Zähler, die dasselbe in derselben Einheit messen. + + + Dieser Zähler hat noch keine Berechnung, und seine Verknüpfungen ergeben keine: + + + Den Ausgangszähler und mindestens einen abzuziehenden Zähler wählen. + + + Die zu addierenden Zähler wählen. + + + Vorschau + + + * Unvollständig – das Ergebnis ist nur belastbar, wo alle Quellen Daten haben: + + + Monat + + + … und {0} weitere. + + + Die Vorschau erscheint, sobald die Berechnung vollständig und gültig ist. + + + Diese Formel lässt sich nicht über Monate addieren: Die Summe wendet sie auf die Summen des Zeitraums an. + + + Ergebnis + + + Summe: {0} ({1}) + + + Summe + + + „Eigene Menge bepreisen“ setzt eine Formel ohne Konstanten, Produkte oder Verhältnisse voraus. + + + „Summe der Kosten der Quellen“ setzt eine einfache Summe von Zählern voraus. + + + „Summe der Kosten der Quellen“ setzt durchgehend einfache Summen voraus, {0} ist aber keine. + + + Eine Kennzahl wird nie bepreist; bitte „Keine Kosten“ wählen. + + + Die Berechnung dreht sich im Kreis: {0}. + + + Eine Kennzahl aus einem Produkt oder Verhältnis braucht eine eigene Einheit. + + + {0} ist eine Kennzahl, daher ist alles, was daraus berechnet wird, ebenfalls eine Kennzahl. + + + {0} ({1}) und {2} ({3}) messen Unterschiedliches; um sie zu verknüpfen, das Ergebnis auf Saldo stellen. + + + Die Formel verweist auf keinen Zähler; eine Zahl allein ist kein Zähler. + + + Zähler zu multiplizieren oder zu teilen ({0}) ergibt ein Verhältnis: Das Ergebnis auf Kennzahl stellen. + + + Das Ergebnis ist auf {0} gestellt, die Quellen messen aber {1}. + + + Die Quellen messen Unterschiedliches ({0}); bitte wählen, was das Ergebnis misst. + + + Ein berechneter Zähler kann nur Verbrauch, Erzeugung, einen Saldo oder eine Kennzahl messen. + + + Die Einheit {0} passt nicht zur Einheit der Quellen ({1}); nur eine Kennzahl darf eine eigene Einheit haben. + + + Die Formel verweist auf diesen Zähler selbst. + + + Die Berechnung von {0} ist ungültig, daher lässt sich diese nicht auswerten. + + + {0} hat noch keine Berechnung, daher lässt sich diese nicht auswerten. + + + {0} ({1}) und {2} ({3}) lassen sich nicht addieren oder subtrahieren: Ihre Einheiten unterscheiden sich. + + + {0} ist kein Zähler. + + + Eine Änderung von Messmodus, Einheit, Anfangs-Zählerstand, Einbaudatum oder Tankkalibrierung baut die Auswertungsdaten dieses Zählers beim Speichern aus seinen Zählerständen neu auf. Die Zählerstände selbst bleiben unverändert. + + + Das Ergebnis misst + + + Automatisch: nicht eindeutig – bitte wählen + + + Verbrauch und Erzeugung gehen in Summen ein; ein Saldo kann negativ sein; eine Kennzahl, etwa ein Verhältnis, wird nie addiert oder bepreist. + + + Einheit des Ergebnisses + + + Leer lassen, um die Einheit der Quellen ({0}) zu verwenden. + + + Leer lassen, um die Einheit der Quellen zu verwenden. + + + Eine Kennzahl aus einem Produkt oder Verhältnis braucht eine eigene Einheit, z. B. kWh/m². + + + Bitte die Berechnung vor dem Speichern vervollständigen oder korrigieren. + + + berechnet + + + ab {0} + + + bis {0} + + + Abziehen + + + Zu addierende Zähler + + + Flussverknüpfungen an diese Summe anpassen + + + Fügt Verknüpfungen von {0} hinzu. + + + Verknüpfungen bestimmen nur die Flussansicht; die Berechnung ändern sie nie. + + + Entfernt Verknüpfungen von {0}. + + + Das würde {0}, eingestellt auf „Immer mitzählen“, aus den Summen verdrängen. + + + Ein Saldo oder eine Kennzahl gehört zu keiner Summe und kann daher nicht mitgezählt werden. + + + Nur eine einfache Summe von Zählern kann anstelle ihrer Quellen gezählt werden. + + + „Immer mitzählen“ würde Energie doppelt zählen: {0} wird bereits gezählt und überschneidet sich mit diesem Zähler. + + + {0} hat die Rolle, die dieser Zähler angibt; bitte zuerst die Rolle klären. + + + Eine Quelle ({0}) gehört zu einer anderen Energieart; diese Summe hier mitzuzählen, würde ihre Energie in zwei Energiearten zählen. + + + Eine Quelle ({0}) zählt in einer anderen Summe als diese Berechnung, etwa Netzbezug oder Erzeugung. + + + „Immer mitzählen“ würde Energie doppelt zählen: Die Quellen dieser Berechnung überschneiden sich ({0}). + + + Diesen Zähler auch dort mitzählen, wo die Verknüpfungen es nicht täten. Eine einfache Summe ersetzt dann ihre Quellen, sodass nichts doppelt zählt. + + + Verknüpfungen und Rollen entscheiden: Ein Hauptzähler zählt, ein Unterzähler erscheint als Aufschlüsselung, ein berechneter Zähler dient nur der Auswertung. + + + Zählt in den Summen der Energieart + + + Diesen Zähler aus allen Summen und der Abrechnung herauslassen. Er lässt sich weiterhin einzeln auswerten. + + + Derzeit in den Summen: {0}. + + + Das gespeicherte „Immer mitzählen“ wird nicht angewendet: + + + Kosten analysieren + + + Summe (die Rechnung) + + + Was sich verändert hat + + + Nach Kategorie + + + Nach Zähler + + + In diesem Zeitraum wurde nichts abgerechnet. + + + Veränderungen gruppieren + + + {0} · {1} ({2}) + + + Anzeige + + + Dieser Zeitraum + + + Posten + + + Veränderung in % + + + in % + + + Kostenaufteilung + + + In diesem Zeitraum wurde nichts abgerechnet. + + + Einspeisevergütung + + + Manuelle Kosten + + + Grundpreise + + + Gemessener Verbrauch + + + Abdeckung und Aktualität + + + Daten vorhanden: {0} + + + Was im gewählten Zeitraum passiert ist, was sich verändert hat und wo sich ein genauerer Blick lohnt. + + + Tabelle ausblenden + + + Verlauf + + + Letzter Wert oder letztes Ereignis: {0} + + + Letzter Monat mit Daten: {0} ({1}) + + + {0} (für {1}) + + + ¹ Verglichen über den Teil, den beide Zeiträume abdecken. + + + über den gemeinsam abgedeckten Teil + + + Zähler: + + + {0} historisch + + + {0} live + + + {0} ohne Daten + + + {0} veraltet + + + In der Analyse öffnen + + + {0} analysieren + + + Dieser Zeitraum: {0} + + + Erste Schritte + + + Anteil + + + Als Tabelle anzeigen + + + Anteil als Balken + + + Eine Gutschrift ist größer als die Kosten, daher wird die Aufteilung als Balken um null statt als Ring gezeigt. + + + Gesamtkosten + + + Noch zählt kein Zähler zu den Summen dieser Energieart. + + + Energieart ansehen: + + + Noch ohne Zähler: + + + bewertet Zähler außerhalb der Rechnung + + + überschneidet sich mit {0} + + + Überlappende Ansichten + + + Diese Kategorien teilen Zähler, manuelle Kosten oder Grundpreise mit einer anderen Kategorie oder bewerten etwas, das nicht abgerechnet wird. Sie stehen nur zur Information hier und werden nie aufsummiert. + diff --git a/src/App/Localization/Strings.resx b/src/App/Localization/Strings.resx index 4927a62..3e53834 100644 --- a/src/App/Localization/Strings.resx +++ b/src/App/Localization/Strings.resx @@ -66,6 +66,213 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Chart: {0}. The table lists the same values. + + + Cost unavailable: {0}. Add a tariff to see costs. + + + – No value (no data, not priced or not calculable) – the table says why. + + + * Partial, estimated or not fully priced – the table says why. + + + Resolution of the data: {0} – these intervals are finer and cannot show it. + + + The data here is coarser than these intervals, so they cannot show it. + + + Change + + + Comparison + + + Cost + + + Details for {0} + + + Values by period + + + This calculation does not add up across periods: its total is calculated over the whole period, not summed from the rows. + + + Period + + + Price coverage + + + Total + + + Add tariff + + + All energy types + + + {0}: the analysis is being prepared. + + + {0} is not linked to anything and is counted as part of {1}. + + + {0}: the unit of {1} cannot be used from {2} – a base price is quoted per day, month or year. + + + {0} was not in service from {1} to {2} while use was measured, so the bill of those months is unavailable. + + + {0}: the billing setup does not fit and was worked around. + + + {0}: the billing setup does not fit and was worked around. {1}. + + + Category #{0} + + + {0}: {1} add nothing to this category's cost – a category prices metered use and feed-in, not calculated views, generation or operating hours. Add the metered meters instead. + + + Check source + + + Confirm calculation + + + {0}: {1} missing from {2} – the credit is left out. + + + Edit calculation + + + Edit categories + + + Edit meter + + + Energy type #{0} + + + Fix tariff + + + {0} and {1} are not linked, so they may count the same energy twice. + + + {0}: the calculation is invalid, so no values can be shown. + + + {0}: the calculation is invalid, so no values can be shown. {1}. + + + {0} is calculated as the sum of its linked meters until you confirm the calculation. + + + {0}: the calculation still needs to be set up. + + + {0}: the saved calculation cannot be read. + + + Manage meters + + + Manual costs dated after today are not counted yet ({0}). + + + Manual costs in another currency were counted as entered ({0}). + + + Meter #{0} + + + Open tariffs + + + {0} may be counted twice. + + + {0}: the price changes inside a reading interval between {1} and {2}, so that cost cannot be split into months and is unavailable. + + + {0}: {1} is quoted in another currency than this instance uses, from {2}. + + + {0}: {1} missing from {2}. + + + {0}: {1} not set up. + + + {0}: {1} does not match the meter's unit from {2}. + + + {0}: values dated after now ({1}) are not counted yet. + + + {0}: values dated after now are not counted yet. + + + Set up calculation + + + Error: + + + Note: + + + Warning: + + + Show all ({0}) + + + Show fewer + + + Show records + + + {0}: the live source has stopped delivering. + + + Needs attention + + + {0}: the totals configuration contradicts itself. + + + {0}: the totals configuration contradicts itself. {1}. + + + {0} and {1}: the totals configuration contradicts itself. + + + {0} and {1}: the totals configuration contradicts itself. {2}. + + + {0} was not found. + + + A tariff's unit could not be checked; it was applied as entered. + + + {0} counts in the bill but has no cost rule, so it is left out. + + + Breadcrumbs + Add @@ -123,8 +330,17 @@ Sort order - - Cost + + {0} less + + + {0} more + + + No change + + + No comparison Actions @@ -135,9 +351,6 @@ Cancel - - Cost (range) - Currency @@ -189,24 +402,12 @@ Preview - - Range - - - All time - - - Last 12 months - - - Last 24 months - - - Last 5 years - Readings: + + Retry + Save @@ -225,9 +426,6 @@ Target - - This year - Type @@ -237,6 +435,21 @@ Value + + Change measured over what both cover: {0} and {1}. + + + Not comparable: the two periods share no covered days, so only the values are shown. + + + {0} compared with {1} + + + No comparison: {0}. + + + (with gaps) + Add connector @@ -399,77 +612,197 @@ yes - - as of {0} + + Contents on {0} Burner runtime - - Consumption by month + + Dipstick of {0}, no delivery since; use since then is not deducted. + + + Dipstick of {0} plus {1} delivered since; use since then is not deducted. + + + Cost + + + At the tank's own tariff + + + Delivered - Deliveries ({0}) + Deliveries: {0} - - Effective rate + + Deliveries in this period + + + Contents, deliveries, usage and cost of every tank: what is known now, and what happened in the selected period. + + + on {0} + + + on {0} · read {1} + + + Estimated now (incl. deliveries since) + + + Needs a recorded tank level. + + + Fill level - {0}% of {1} {2} + {0} of {1} + + + Not within 100 years Forecast to empty + + Needs recorded tank levels. + + + No forecast + + + Nothing was used between the dipsticks. + + + That date has passed – record a tank level. + + + The dipsticks of the last year span less than {0} days. + + + The last dipstick is {0} days old; a forecast needs one from the last {1} days. + + + Last dipstick + - No deliveries recorded. + No deliveries in this period. - - No consumable meters found. Add a meter with mode + + None - - , or load the reference data from + + No tank level recorded yet. - - and set up its tank in + + No tank level was recorded by then. - - Consumables + + Tanks are meters with the mode “{0}”. Add one in the meter list and set up its tank in its settings, or import its data from a CSV file. + + + No tank yet + + + Record tank levels regularly: usage is known between two dipsticks. + + + From the last dipstick, independent of the selected period + + + Now - ({0} {1}/day) + {0} per day + + + Selected period + + + Burn rate + + + Used ÷ burner runtime in this period + + + Fixed rate from the tank settings + + + Needs a runtime meter of the same energy type. + + + The runtime meter does not count hours. Record delivery - - Tank level + + Record tank level + + + From {0} + + + Runtime ({0}) + + + Set up tank {0} has no tank set up yet — its level, fill and forecast need the tank's capacity and dipstick calibration. - - {0} used + + Usage per interval ({0}) - - Used (range) + + Used + + + From dipsticks and deliveries + + + calculated + + + Cost: {0} + + + Factor + + + Formula + + + This calculation has no sources yet. + + + not linear + + + Share of result + + + Source meter + + + Sources + + + Used + + + “Used” counts only the days all sources cover; the share is factor × used amount. + + + Own value Category - - Now - - - Prev - - - Latest month with data - - Costs are reported per cost category, and there is none yet: - - - No costs this year yet. They appear once a meter in a cost category records consumption with a tariff in effect. + The composition splits the bill by cost category, and there is none yet: No meter counts toward a cost category yet. Pick categories when editing a meter, or assign them per category: @@ -480,14 +813,29 @@ No tariffs yet — consumption needs a price before it has a cost: - - This month + + Standing charge — {0} - - What cost more / less (year vs last year) + + Standing charge — global - - What costs most (this year) + + Uncategorized + + + Data is available from {0} to {1}. + + + Go to latest data + + + No data for this period + + + No data yet + + + This period has not started yet. Add energy type @@ -513,6 +861,9 @@ Delete energy type + + Define the kinds of energy and utilities you meter: name, unit, icon and default mode. To analyse an energy type's use and cost, open it under Energy types in the menu. + Display name @@ -537,11 +888,101 @@ Key, display name and base unit are required. - - Consumption + + Energy type definitions - - Upstream of + + Add a connection + + + Connect + + + {0} — {1} + + + Choose a meter + + + This period + + + In the diagram + + + From + + + Into + + + Connected {0} → {1}. + + + The connection could not be saved. Please try again. + + + {0} will flow into {1}. + + + Removed {0} → {1}. + + + Connections + + + No connections yet. + + + A connection runs from a meter into one that measures part of its energy (house → car), or from a supply into what it feeds (grid → house). Connections draw the flow and make a part a breakdown that is never added on top. They never change what a calculated meter calculates. + + + A connection needs at least two meters of this energy type. + + + Connections — {0} + + + {0} ({1}) + + + Feed-in credited: {0} + + + The bill belongs to the energy type as a whole. Each meter's own page shows what that meter costs by its own rule. + + + Counted: {0} + + + Coverage + + + What this energy type used, supplied and cost in the chosen period. Its definition is edited under Configuration. + + + Done + + + Input of a calculated sum + + + Estimated, capped at what {0} measured + + + Estimated share (split across its upstream meters) + + + Measured part + + + Not measured by a meter below it + + + Edit calculation + + + Edit definition Energy @@ -550,34 +991,400 @@ Flow - Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder. + Where the energy went in this period, in {0}: a ribbon's width is its amount, and “Other” is what no meter below accounts for. - - {0} flow + + flows into - - No meter chain configured yet. In + + The flow as a table - - → edit a sub-meter and set its + + From - - to show where the main meter's flow divides (e.g. main → car, pool, other). + + The meter that measures the whole, or the supply - - upstream meter(s) + + How these meters count - - No consumption recorded for these meters in the selected range yet. + + Drawn - - No meters for this energy type yet. Add meters in + + Largest changes by meter - - , or load the reference data from + + Each change is measured over the dates both periods cover. - - Top-level throughput + + Legend + + + Dashed outline: input of a calculated sum + + + Dotted outline: estimated share + + + Filled: measured + + + Grey: not measured below + + + Manage connections + + + incl. manual costs {0} + + + {0} ({1}) + + + What each meter measured in this period, and how it counts in the totals. + + + {0} calculates with {1} in its formula; this connection only draws it. + + + Showing {0} of {1} meters. Compare the others on: + + + No meter has data over matching dates in both periods. + + + Choose a comparison to see which meters changed most. + + + These meters are not connected yet, so there is no flow to draw. Connections are optional: the totals and the history work without them. + + + No meter of this energy type counts in a total yet. See how each one is set up under + + + No meters belong to this energy type yet. + + + No meter of this energy type shows “{0}”. + + + Add a meter with this energy type, or import the reference data. + + + Not drawn: a calculation that is not a plain sum + + + Not drawn: nothing measured in this period + + + Not drawn: measured in {0} + + + This energy type does not exist (any more). + + + This energy type has no total for “{0}”; see the individual meters. + + + Open history + + + {0} (another energy type) + + + These meters are already connected this way. + + + {0} is calculated from its connections until its calculation is confirmed, so they cannot be changed here. Set its calculation instead. + + + This would make a loop: {0}. + + + This connection no longer exists. + + + Both meters must belong to the same energy type. + + + A meter cannot flow into itself. + + + One of these meters no longer exists. + + + Remove connection {0} → {1} + + + incl. standing charges {0} + + + Flow + + + History + + + Meters ({0}) + + + Overview + + + Into + + + The meter that measures part of it, or is fed by it + + + Trend + + + Trend: {0} + + + History view + + + Individual meters + + + Each meter on its own. They can overlap, so do not add them up: below it says how each one counts. + + + Total + + + The energy type's totals: every meter counted once, a part never on top of its whole. + + + Not connected; taken as part of {0}, which measures all use. + + + Part of {0}: shown, but never added on top. + + + Nothing measures above it, so it counts in the total use. + + + Connected above the total-use meter {0}; not counted until that is resolved. + + + Counted through {0} instead. + + + {0} holds the same role; not counted until that is resolved. + + + What went back to the grid; never counted as use. + + + Generated here; counted in generation. + + + What the grid supplied: shown beside the total use, never added to it. + + + Set to always count; it replaces {0}. + + + Set to always count. + + + Set never to count. + + + Operating time, counted on its own. + + + Holds the total-consumption role, so it alone is the total use. + + + Calculated from {0}, which are counted already: a view for analysis, never added to the totals. + + + A calculated view for analysis, never added to the totals. + + + Tank level + + + Consumption + + + Cost + + + Export + + + Generation + + + Net + + + Runtime + + + Analysis being prepared + + + Invalid calculation + + + Calculation to confirm + + + Calculation needs configuration + + + Unreadable calculation + + + Possible double counting + + + Recorded after now + + + Source not delivering + + + Conflicting totals configuration + + + Meter not found + + + The resolution in the link was not recognized; the default is used. + + + The comparison in the link was not recognized; the default is used. + + + Some ids in the link are not valid and were ignored. + + + The measure in the link was not recognized; the default is shown. + + + The period in the link was not recognized; the default period is shown. + + + The dates in the link are not a valid range; the default period is shown. + + + The selection in the link was not recognized; the default is shown. + + + Too many meters were selected; only the first ones are shown. + + + Feed-in credit + + + Own meter price + + + Unit price + + + Billed by grid import + + + Nothing billed + + + Billed by total use + + + Automatic + + + Daily + + + Monthly + + + Weekly + + + Yearly + + + Complete + + + Cannot be calculated + + + No data + + + Partial + + + Being prepared + + + Only coarser data + + + No comparison + + + Previous period + + + Same period last year + + + A chosen year + + + All history has nothing before it to compare with + + + The comparison period lies in the future + + + The period has not started yet + + + The comparison period has no matching days + + + No year chosen + + + There is no data to compare + + + Comparable + + + No comparison chosen + + + A year comparison needs a calendar year + + + The comparison lies outside the supported dates + + + That is the same year + + + Category + + + Standing charge + + + Uncategorized Consumption @@ -585,12 +1392,75 @@ Generation + + Grid meter not in service + + + Billing setup needs attention + + + Category members add no cost + + + Manual cost dated in the future + + + Manual cost in another currency + + + Missing price + + + Price change inside a reading interval + + + Tariff unit not checked + + + Virtual meter in the bill without a cost rule + + + Not priced (no tariff) + + + Partly priced + + + Unavailable (tariff gap) + + + Priced + + + Unavailable (unit) + Home Assistant MQTT broker + + Historical (imported or manual) + + + Live + + + No data yet + + + Stale – source not delivering + + + Meter data and manual costs + + + Manual costs + + + Meter data + Delivery @@ -606,6 +1476,24 @@ Tank level + + Its part of the bill + + + Feed-in credit (not billed) + + + Not costed + + + Its own quantity at the unit price + + + Sum of its sources' costs + + + Its quantity at the unit price (not billed) + Correction @@ -645,6 +1533,156 @@ Virtual + + Generation is never billed + + + The calculation has no cost rule + + + Costed + + + The calculation cannot be evaluated + + + Operating time is not billed + + + A source calculation is not a plain sum, so the sources' costs cannot be added + + + Measures what is fed into the grid; it is credited and never counted as consumption. + + + Measures what is drawn from the grid; this is what the supplier bills. + + + Measures everything the site uses; it becomes the energy type's total use. + + + Grid export + + + Grid import + + + Total consumption + + + Analysis only + + + Breakdown of a counted meter + + + Excluded by setting + + + Counted as export + + + Counted as generation + + + Counted as grid import + + + Included by setting + + + Not counted (configuration conflict) + + + Counted as runtime + + + Counted as total use + + + The grid import and the total-consumption meter are not linked, so they may count the same energy twice + + + The meter is not linked and is counted as part of the total consumption + + + All available history + + + Custom dates + + + Last 12 months + + + Last 24 months + + + Last month + + + This month to date + + + Previous year + + + This year to date + + + Calculated + + + Estimated + + + Imported + + + Manual + + + Measured + + + Opening balance + + + Consumption + + + Cost + + + Export + + + Generation + + + Indicator + + + Net + + + Runtime + + + Cost category + + + Energy type + + + Meter + + + Meter comparison + + + All energy types + Anomaly @@ -672,6 +1710,33 @@ Measured + + Coarser than monthly + + + Daily + + + Hourly or finer + + + Monthly + + + Weekly + + + Calculated (sum of linked meters, to confirm) + + + Total + + + Physical meter + + + Calculated (formula) + Home Assistant @@ -738,6 +1803,51 @@ Meter + + Export + + + Generation + + + Grid import + + + Runtime + + + Total use + + + Always count + + + Automatic (by topology) + + + Never count + + + The meter links form a loop + + + Two meters hold the same role at the same time; the one created first keeps it + + + “Always count” was not applied, because the same energy is already counted + + + The meter's role does not fit its mode and is ignored + + + The meter has its own price, but its unit does not fit the meter it would be taken out of, so it stays in that meter's bill + + + The total-consumption meter is linked below another meter + + + The meter has its own price, but no bill uses it – link it below the meter that supplies it + Allowed @@ -747,6 +1857,141 @@ not supported on this install + + The analysis is being prepared + + + The data is too coarse for this resolution (e.g. monthly readings viewed by day) + + + The calculation refers back to itself + + + The calculation is invalid + + + Calculated as the sum of its linked meters until confirmed + + + A source meter has no data here + + + No data covers this period + + + The formula has no finite result (e.g. division by zero) + + + No issue + + + No tariff is set up + + + Not yet occurred + + + Includes a first reading whose start is unknown + + + Data covers only part of this period + + + No tariff covers these months + + + Some values here are dated after now and not counted yet + + + The register jumps without a recorded swap or reset + + + No samples for too long + + + The tariff's unit does not match the meter + + + No cost (analysis only) + + + Price its own quantity + + + Sum of the sources' costs + + + Invalid definition + + + Legacy – please confirm + + + Unreadable definition + + + Needs configuration + + + Valid + + + The cost rule “own quantity at the unit price” needs a linear formula + + + The cost rule “sum of the sources' costs” needs a plain sum + + + An indicator cannot have a cost rule + + + The calculation depends on itself through other calculated meters + + + An indicator needs a result unit + + + A source is an indicator, so the result has to be one too + + + The formula adds or subtracts different kinds of quantity, but the result is not declared as net + + + The formula refers to no meter + + + Multiplying or dividing meters needs the result kind “Indicator” + + + The result kind contradicts the source meters + + + The result kind has to be chosen, because the source meters do not decide it + + + A calculated meter cannot have this result kind + + + The result unit contradicts the source meters + + + The formula refers to the meter itself + + + A calculated source meter has an invalid calculation + + + A calculated source meter has no calculation yet + + + The formula cannot be read + + + The formula adds or subtracts meters in different units + + + The formula refers to a meter that does not exist + For local debugging, enable the {0} environment by setting the {1} environment variable to {0} and restarting the app. @@ -774,9 +2019,21 @@ Request ID: + + Quantity: {0} + + + Some meters have no tariff + + + A tariff unit could not be checked + Other ({0}) + + percentage not applicable + Row {0}: register for meter {1} fell {2}→{3}; a swap event was created (please check). @@ -1023,12 +2280,12 @@ An unhandled error has occurred. - - about the same - Add first reading + + Add tariff for this meter + Add reading @@ -1038,6 +2295,18 @@ Add source + + After now + + + Dated after now: listed here, not counted in any figure yet. + + + All events in this period + + + Amount ({0}) + Set up another connector @@ -1053,8 +2322,35 @@ (baseline {0}) + + Problems + + + The stored calculation is invalid, so no values can be shown. Fix the problems below in the editor. + + + No calculation is stored yet: the meter is calculated as the sum of its linked meters until you confirm it in the editor. + + + The stored calculation cannot be read. Open the editor and save the calculation again. + + + This meter has no calculation yet, and its links do not imply one. Set it up in the editor. + + + The calculation is valid. Its values are computed from its source meters whenever they are read — it has no readings of its own. + + + This calculation cannot be evaluated ({0}), so no values can be shown. + + + Flow connections between meters describe topology only; they never change this calculation. + - {0} {1} since last reading + {0} {1} since the previous reading + + + {0} of {1} Component @@ -1074,8 +2370,20 @@ '{0}' is a {1} connector; a {2} source needs {3}. - - Cost this year + + Cost of {0} + + + Rule: {0} + + + The stored rule “{0}” does not fit this formula, so the meter is not costed. + + + Cost rule + + + Cost (set it up once; every source then just picks it). @@ -1083,8 +2391,11 @@ create one + + Data available + - Below the last reading ({0} {1}) on a register that only counts up, so it would be rejected. If the meter was swapped or its counter reset, record that first — the value you typed is kept. + Below the previous reading ({0} {1}) on a register that only counts up, so it would be rejected. If the meter was swapped or its counter reset, record that first — the value you typed is kept. Delete the {0} of {1}? The new register's start reading recorded with it is removed too, and consumption is recomputed. @@ -1110,6 +2421,18 @@ Delete source + + Select a bar or a table row to look closer — down to the records where the data is no finer. + + + Select a bar or a table row to look closer. + + + Edit calculation + + + Edit connector + Edit meter @@ -1119,8 +2442,8 @@ enable it - - Open the {0} flow + + Open the history of {0} Enter a value @@ -1143,6 +2466,9 @@ Flags + + Freshness + From @@ -1161,14 +2487,14 @@ Came from an import — revert that import to remove it. + + In this period + Kind - - {0} this month - - - last month {0} {1} + + Last reading or event: {0} Last reading {0} {1} on {2}. @@ -1176,11 +2502,20 @@ Last value - - · {0} last year + + The linked meters differ in unit or kind, so their sum is not clear - - Lifetime total + + It has no linked meters of its energy type. + + + A linked meter measures something that is not added up + + + A linked calculated meter needs to be set up first + + + A linked meter does not exist Local time in {0}. @@ -1194,14 +2529,20 @@ New source + + Enter a first reading, or connect a live source. A single reading already counts against the meter’s baseline. + + + Record a tank level; deliveries and later levels then give the consumption. + + + Its source meters have no data yet — values appear here as soon as they do. + no - - no basis yet - - no change since last reading + no change since the previous reading No {0} connector yet — @@ -1209,15 +2550,36 @@ No normalized consumption yet. + + No data yet + No events recorded yet. + + No events in this period. + + + No formula stored. + + + No normalized data in this period. + No raw readings. + + No readings in this period. + No readings yet — prefilled with this meter's baseline ({0} {1}). + + The calculation names no meters yet. + + + The consumption or generation derived from the readings and events, in {0} — what charts, totals and costs are built from. Derived and reproducible: rebuilt whenever a reading or event changes. + No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant. @@ -1227,6 +2589,21 @@ No applicable tariffs. + + Converted from operating hours at the tank’s fixed rate — an estimate, not a measured volume. + + + The rate is not per hour, so the amounts are not in its quantity unit; give the source a scale to a rate per hour. + + + The unit is not a rate, so the values are read as a rate per hour. + + + The fixed rate is per hour, but the register does not count hours, so the volume is off by the register’s scale. + + + The calculation declares no result, so consumption in the meter’s unit is assumed until it is saved. + Meter #{0} not found. @@ -1236,6 +2613,27 @@ Offset + + The first reading was counted against the baseline ({0} {1}) from an unknown start, so that amount is an opening balance: it is shown, but not compared or projected. An install date gives it a start. + + + Go to tariffs + + + Newer + + + Newest + + + Older + + + Rows {0}–{1} of {2} + + + Pages + Pick a {0} connector for this {1} source. @@ -1260,18 +2658,24 @@ '{0}' is {1}, needs {2} - - ≈ {0} {1} by month end - - - ≈ {0} {1} full year - Quality + + Data quality and coverage + + + Measures + + + {0} in {1} + Reading deleted — consumption recomputed. + + The reading could not be saved. Please try again. + Reading ({0}) @@ -1284,14 +2688,14 @@ Reading saved: {0} {1}. - - Readings + + Raw readings are the audit record: every value exactly as it arrived, in the register’s own unit, never changed to fix a figure. All of them are kept — raw retention is not enforced. The analysis reads the normalized history derived from them, not this list. - - Most recent {0} normalized deltas. + + Through calculated sources it reads: - - Most recent {0} (raw, immutable audit truth). Times in {1}. + + {0}: {2} dated after now ({3}) is not counted yet. Rows: {1}. Record event @@ -1305,11 +2709,11 @@ Record tank level - - Meter register details + + Meters in the formula - - Register span + + Register: {0} ({1}) → {2} ({3}) {4} This meter already has a reading at that time — saving replaces its value. @@ -1317,9 +2721,24 @@ Replaces the new meter's start value recorded with the swap — consumption across the swap stays correct. + + Resolution + + + Days and weeks show no values; a month opens its records. + + + Days and weeks show no values, because the source data is no finer. + + + Result + retired + + retired {0} + Save reading @@ -1335,9 +2754,21 @@ S/N {0} + + Set install date + Set up tank + + Show all dates + + + Show calculation + + + Show records + That clock time never happened in {0} — the clocks moved forward. Pick another time. @@ -1347,33 +2778,57 @@ Source saved. + + Live sources that deliver readings for this meter, each through a connector. + + + Source meters + Source type - Below last reading — swapped or reset? + Below previous reading — swapped or reset? - - Consumption ({0}) + + Analysis + + + Calculation - Events ({0}) + Events + + + Normalized data - Readings ({0}) + Readings - Sources ({0}) + Sources - Tariffs ({0}) + Tariffs A tank's consumption comes from tank levels and deliveries, which are recorded as events — a reading entered here would change nothing. + + {0} {1} {2} ({3}) + + + applies now + open + + A price runs until the next one of its kind starts. For each component, the meter’s own price wins over its energy type’s, which wins over a global one. + + + The prices that can apply to this meter. + Time @@ -1383,33 +2838,27 @@ Time path (optional, e.g. Time) + + Times in {0}. + To MQTT topic (e.g. tele/plug1/SENSOR) + + unknown meter + + + Value ({0}) + Value kind Value path (e.g. ENERGY.Total; blank = bare scalar) - - A virtual meter has no readings of its own — enter readings on the meters it adds up. - - - Virtual meter — it has no readings of its own; its value is the sum of the upstream meters linked to it. - - - See it in the flow view. - - - vs last month - - - {0} vs {1} last year - yes @@ -1566,6 +3015,9 @@ Used since the last level: {0} {1} + + A calculated meter adds no cost to a category: categories price the metered meters it reads. Its own cost follows its cost rule. + Showing {0} of {1} — keep typing to narrow down. @@ -1581,9 +3033,21 @@ Add meter + + All energy types + Something changed while this dialog was open (a meter or category was deleted). Nothing was saved — check and save again. + + Counts as + + + Data + + + In this period + Cost categories @@ -1593,15 +3057,30 @@ Already counted through its energy type in: {0} + + {0} meters + + + {0} of {1} meters + Delete '{0}'? This cannot be undone. Delete '{0}'? This will also delete {1} reading(s) and {2} consumption row(s). This cannot be undone. + + Delete {0} + Delete meter + + These virtual meters calculate with it and will no longer be correct until you edit them: {0}. + + + Every meter with what it measured in the chosen period. Open one for its analysis, readings and settings. + Edit {0} @@ -1611,9 +3090,18 @@ No meters yet. Add one, or go to + + {0}, data up to {1} + Initial register baseline + + Installed on + + + Optional. A register's first reading counts from this date; without it, when that first amount accrued is unknown. + rate in this meter's unit (e.g. kW for kWh, L/h for L): a source reporting W or L/min should carry a scale factor to convert it first. @@ -1638,20 +3126,23 @@ New meter - - no - No cost categories yet, so this meter's cost will not show on the dashboard. Create one in No meter matches “{0}”. - - PV role (optional) + + Open energy type - - Tag the house meter total_load and the grid meter grid_import to unlock self-consumption, autarky and savings on the Solar page. + + {0}: {1} + + + Add reading + + + Record tank level Settings that affect consumption changed — it will be recomputed on save. @@ -1662,9 +3153,33 @@ Name, energy type and unit are required. + + Retired + Was the physical meter replaced? Record a meter swap instead: history, sources, flow links and cost categories stay on this meter, without a gap. Retire a meter only when it is gone for good. + + Retired on + + + Optional. The meter counts in totals and the bill only up to this date, and keeps its role for that time. + + + The retirement date cannot lie before the install date. + + + Role in its energy type (optional) + + + {0} holds this role now; saving moves it to this meter. + + + Decides what this energy type's totals and bill count. Each role belongs to one meter in service. + + + Role moved from {0}. + — none — @@ -1674,9 +3189,6 @@ Serial number (optional) - - Sources - Capacity @@ -1711,19 +3223,70 @@ Sub-meter of (upstream meters) - Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2). + A virtual meter has no readings of its own: it is calculated from other meters, and its analysis comes from theirs. - - yes + + Show details - - Admin + + Close + + + Next month {0} + + + Next year {0} + + + Previous month {0} + + + Previous year {0} + + + Clear + + + Decrement + + + Increment + + + Not a valid date time + + + Not a valid number + + + Loading… + + + Toggle {0} + + + Open + + + Scroll tabs left + + + Scroll tabs right + + + Analysis + + + Configuration Connectors - Oil / consumables + Tanks & consumables + + + No tank set up yet Cost categories @@ -1731,39 +3294,69 @@ Energy types + + Energy types could not be loaded. + - Import + Data import Meters + + None defined yet + Overview - - Meters & data - - - Energy - Settings Solar / PV + + No generation meter yet + + + Specialized views + Tariffs - - Trends - Sorry, the content you are looking for does not exist. Not Found + + No cost – members not billed + + + Its members are calculated views, generation or operating hours, which a category does not price. + + + This panel could not be loaded. + + + Updating failed. The figures shown are from the previous selection. + + + Check again + + + MeterVault is rebuilding this history after an update. Nothing is lost; the figures appear here once it is done. + + + Analysis being prepared + + + Projection (straight-line from {0} days) + + + Projection (straight-line from {0} days): ≈ {1} + The session has been paused by the server. @@ -1791,15 +3384,33 @@ Please retry or reload the page. + + Updating… + Flow diagram + + calculated + + + estimated + No flow to show for this period. Access & ingestion + + Analysis data + + + Charts and totals read rollups built from consumption in the configured time zone. A meter built with an older revision or in another zone shows as “being prepared” until it is rebuilt at the next start. + + + The analysis state could not be read. + closed (401) @@ -1842,6 +3453,24 @@ Locale & time + + Meters with current analysis data + + + {0} of {1} + + + {0} being prepared + + + none + + + Consumption built with + + + Time zone of stored days and months + off @@ -1851,21 +3480,72 @@ Raw-reading retention - - {0} days + + Waiting to be rebuilt at the next start + + + Not enforced + + + Raw readings are kept indefinitely (configured: {0} days). Every recompute rebuilds a meter from its readings, so deleting old readings would erase that part of its history. Reverse-proxy trust + + not built yet + + + revision {0} (current: {1}; rebuilt at the next start) + + + revision {0} + Seed reference data on start Timezone + + Calculated meters + + + {0} – differs from the configured zone; rebuilt at the next start + Autarky + + Share of the site's use covered by solar + + + Needs self-consumption and the site's total use – see below. + + + {0} minus {1} + + + Measured + + + {0} plus {1} + + + Cannot be calculated: the meters are in different units ({0}). + + + Generation, self-consumption and feed-in of your solar installation, and what it saved, for the selected period. + + + Feed-in + + + Feed-in credit: {0} + + + Needs a grid export meter, or total consumption and grid import meters – see below. + Generation @@ -1875,35 +3555,83 @@ Generation by meter - - Grid draw {0} kWh + + No generation meter counts towards the total – see the meters below. - - No generation meters found. Add a meter with mode + + Not added (other units): {0} - - , or load the reference data from + + Grid import {0} - - in + + Yes + + + Counts in the total + + + No + + + No (calculated) + + + Record readings of the generation meters, or import them from a CSV file. + + + Solar figures come from meters with the mode “{0}”. Add one in the meter list, or import its readings from a CSV file. + + + No generation meter yet + + + All meters of {0} + + + Give one of these meters the role in its settings: + + + {0}: no meter yet + + + Based on: + + + With it, feed-in is measured instead of calculated, and credited at the feed-in tariff. + + + With it, self-consumption can be calculated and valued at the grid price. + + + Together with a grid import meter, it gives self-consumption, autarky and savings. - Savings (Ersparnis) + Savings + + + Self-consumption at the grid price of {0} + + + Needs self-consumption and a grid import or total consumption meter for the price – see below. Self-consumption + + Needs total consumption and grid import meters, or a grid export meter – see below. + + + Complete the solar figures + - {0}% of generation + {0} of generation - - Set the PV role of your house meter to + + Solar figures in detail - - and of your grid meter to - - - to unlock self-consumption, autarky and savings — in the meter editor under + + Use {0} Add tariff @@ -1917,21 +3645,54 @@ Delete tariff + + Prices by scope and date. A meter's own price wins over its energy type's, which wins over a global one; each month is priced with the tariff valid on the 15th. + Edit tariff No tariffs yet. Add one, or load the reference data from + + No tariff applies here yet. + + + Global tariffs only. + + + Tariffs that can price {0}: its own, its energy type's and global ones. + + + Tariffs for {0}: the energy type's own, its meters' and global ones. + New tariff + + Not applied yet + + + Bonus, discount and tax tariffs are stored but not applied to costs yet. + Notes (optional) open + + day + + + month + + + quarter + + + year + Global @@ -1944,12 +3705,54 @@ Type: {0} + + Show all tariffs + Unit and valid-from are required. + + Fix the unit first: it does not fit what this tariff would price. + + + The unit is quoted in {0}, but this instance bills in {1}. + + + Fits {0} ({1}). + Unit (e.g. EUR/kWh, EUR/m3, EUR/month) + + Does not fit {0}, which measures in {1}. + + + Nothing in this scope is billed or credited yet, so the quantity cannot be checked. + + + Read as {0} per {1}, spread over the days of that period. + + + Read as {0} per {1}. + + + This unit cannot be read, so it cannot be checked. It would be applied as written, with a warning beside the cost. + + + This unit cannot be read; the base price would be taken per month, with a warning beside the cost. + + + “{0}” cannot be spread over days; use a day, month, quarter or year. + + + Cannot be checked against {0} ({1}); it would be applied with a warning. + + + A base price is a currency per day, month, quarter or year, e.g. EUR/month. + + + A unit or feed-in price is a currency per quantity, e.g. EUR/kWh, ct/kWh or EUR/100 L. + Valid from @@ -1959,20 +3762,71 @@ Valid to (empty = open-ended) - - Monthly cost + + A price of 0 makes this period free of charge. - + + Enter a value. + + Apply - - Last 48 months + + Automatic ({0}) - - Cost trend + + Interval - - Total over range: {0} + + Compare with + + + Export CSV + + + From + + + Days after today are not counted yet. + + + Choose a start date on or before the end date. + + + Period and display + + + Show + + + Period + + + Period details + + + The records of the whole range are listed, including any dated after now (marked). + + + Reset + + + Dates are in the {0} time zone. + + + To + + + The interval “{0}” would need {1} points; at most {2} can be shown. + + + (too many points) + + + Figures run up to today, {0}. + + + Use “{0}” This pulls the latest source, rebuilds it, and restarts the service. It takes a few minutes, during which MeterVault is unavailable. Readings are not affected — ingestion resumes on restart. @@ -2010,4 +3864,625 @@ Update started. The service restarts when the rebuild finishes — this usually takes a few minutes. + + Analyse {0} + + + Keep at least one meter selected. + + + calculated + + + {0} (calculated) + + + Calendar year + + + Each meter of this category is shown on its own. They are not added up, because meters can overlap — a sub-meter is part of the meter above it. + + + Choose a coarser interval to see the figures. + + + Compare the first {0} + + + Compare these meters + + + With more than {0} series, the comparison values are in the table rather than the chart. + + + Costed as: {0} + + + Counted: {0} + + + Explore consumption, generation and costs over time — for everything, one energy type, a cost category or meters side by side. + + + {0} in {1}: {2} + + + Meters (up to {0}) + + + Nothing has been recorded for this selection yet. Readings are added on a meter's page, spreadsheets under + + + No cost: {0} + + + “{0}” is not available for this selection; showing “{1}” instead. + + + “{0}” is not available for this selection. + + + Some meters in the link no longer exist and were left out. + + + Not shown for {0}: {1} — they measure something else. Choose their measure to compare them. + + + Not shown: {0} — they have no cost of their own. + + + Open energy type + + + Open meter + + + This category overlaps with other categories or prices what the bill does not, so it is a view on the bill, not a slice of it. + + + Feed-in credit {0} + + + Manual costs {0} + + + Standing charges {0} + + + Metered use {0} + + + The meters of {0} measure different things, so they cannot be shown as one quantity: + + + {0} has {1} meters; at most {2} can be shown side by side. Its cost can be analysed as a whole. + + + {0} has no meters — its costs are entered by hand — so it can only be analysed by cost. + + + What this link points to no longer exists. + + + {0} (retired) + + + What to analyse + + + Analyse + + + Show all energy types + + + Show the cost + + + {0} per period + + + Values per period + + + At most {0} meters can be compared at once. Remove one before adding another. + + + Total cost + + + Total use and grid import are shown side by side: they measure different things and are never added up. + + + another meter + + + Automatic: {0} + + + Calculation + + + The meters could not be loaded, so the calculation cannot be edited. Close the dialog and try again. + + + generation is never billed, so its sources cost nothing. + + + an indicator is never costed. + + + {0} is not a plain sum. + + + only a formula without constants, products or ratios can be priced as a quantity. + + + only a plain sum of meters adds up its sources' costs. + + + Cost + + + The calculation has no cost of its own; it is for analysis only. + + + Prices the calculated quantity with the tariff for this meter, its energy type or the global one. + + + The chosen cost rule does not fit the calculation any more and was set back to automatic. + + + Adds what the sources cost at their own tariffs, without standing charges. + + + {0} is not available: {1} + + + Formula + + + The formula is empty. + + + “{0}” is not a number; write decimals with a point, e.g. 0.5. + + + “{0}” is not a valid meter number. + + + The parenthesis at position {0} is never closed. + + + Parentheses are nested more than {0} levels deep. + + + The formula is longer than {0} characters. + + + “{0}” is not allowed in a formula (position {1}). + + + The formula ends where a meter or a number is expected. + + + “{0}” does not belong at position {1}. + + + “{0}” is not a meter; refer to meters as m and their number, e.g. m12. + + + e.g. m4 + m5, or (m1 - m3) * 0.5 + + + Reads as: + + + An indicator is shown on its own: it is never added to totals or costed. + + + Insert a meter + + + the links go round in a circle: {0}. + + + the sum they imply is not valid. + + + the linked meters measure different things ({0}). + + + the linked meters measure in different units ({0}). + + + no meter of its energy type is linked to it. + + + {0} cannot be added up. + + + {0} has no calculation itself. + + + a linked meter no longer exists. + + + the linked meters measure {0}, which needs the result chosen explicitly. + + + This meter has no calculation of its own yet. Its links imply {0}; check it below and save to confirm it. + + + Outside these dates the meter counts as a known zero in totals, bills and calculations that use it, not as missing data. + + + The saved calculation could not be read completely. What could be read is shown so you can repair it. + + + Start from + + + mixed + + + The meters measure different things (for example grid import and export): set the result to Net. + + + Formula + + + Refer to a meter as m and its number (m4), and combine with + - * /, parentheses and numbers with a decimal point (0.5). + + + Difference + + + Starts from one meter and subtracts the others. Only meters in the same unit are offered. + + + Sum + + + Adds up the meters you pick. Only meters that measure the same thing in the same unit are offered. + + + This meter has no calculation yet, and its links do not imply one: + + + Pick the meter to start from and at least one meter to subtract. + + + Pick the meters to add up. + + + Preview + + + * Incomplete – the result is only confident where every source has data: + + + Month + + + … and {0} more. + + + The preview appears once the calculation is complete and valid. + + + This formula does not add up across months: the total applies it to the period's totals. + + + Result + + + Total: {0} ({1}) + + + Total + + + “Price its own quantity” needs a formula without constants, products or ratios. + + + “Sum of the sources' costs” needs a plain sum of meters. + + + “Sum of the sources' costs” needs plain sums throughout, but {0} is not a plain sum. + + + An indicator is never costed; choose “No cost”. + + + The calculation goes round in a circle: {0}. + + + An indicator built from a product or ratio needs its own unit. + + + {0} is an indicator, so anything calculated from it is an indicator too. + + + {0} ({1}) and {2} ({3}) measure different things; to combine them, set the result to Net. + + + The formula refers to no meter; a number alone is not a meter. + + + Multiplying or dividing meters ({0}) gives a ratio: set the result to Indicator. + + + The result is set to {0}, but the sources measure {1}. + + + The sources measure different things ({0}); choose what the result measures. + + + A calculated meter can only measure consumption, generation, a net balance or an indicator. + + + The result unit {0} does not match the sources' unit {1}; only an indicator may have a unit of its own. + + + The formula refers to this meter itself. + + + The calculation of {0} is invalid, so this one cannot be evaluated. + + + {0} has no calculation yet, so this one cannot be evaluated. + + + {0} ({1}) and {2} ({3}) cannot be added or subtracted: their units differ. + + + {0} is not a meter. + + + Changing the mode, unit, baseline, install date or tank calibration rebuilds this meter's analysis data from its readings when you save. The readings themselves never change. + + + The result measures + + + Automatic: cannot tell – please choose + + + Consumption and generation add up in totals; a net balance may be negative; an indicator, such as a ratio, is never added up or costed. + + + Result unit + + + Leave empty to use the sources' unit ({0}). + + + Leave empty to use the sources' unit. + + + An indicator built from a product or ratio needs its own unit, e.g. kWh/m². + + + Complete or correct the calculation before saving. + + + calculated + + + from {0} + + + until {0} + + + Subtract + + + Meters to add up + + + Match the flow links to this sum + + + Adds links from {0}. + + + Links only draw the flow view; they never change the calculation. + + + Removes links from {0}. + + + This would push {0}, which is set to “Always count”, out of the totals. + + + A net balance or an indicator belongs to no total, so it cannot be counted. + + + Only a plain sum of meters can be counted in place of its sources. + + + “Always count” would count energy twice: {0} is already counted and overlaps this meter. + + + {0} holds the role this meter declares; resolve the role first. + + + A source ({0}) belongs to another energy type; counting this sum here would count its energy in two types. + + + A source ({0}) counts in another total than this calculation would, such as grid import or generation. + + + “Always count” would count energy twice: the sources of this calculation overlap ({0}). + + + Count this meter even where the links would not. A plain sum then replaces its sources, so nothing is counted twice. + + + Links and roles decide: a main meter counts, a sub-meter is shown as a breakdown, a calculated meter is for analysis only. + + + Counts in the energy type's totals + + + Leave this meter out of every total and the bill. It can still be analysed on its own. + + + In the totals now: {0}. + + + The saved “Always count” is not applied: + + + Analyse costs + + + Total (the bill) + + + What changed + + + By category + + + By meter + + + Nothing was billed in this period. + + + Group changes + + + {0} · {1} ({2}) + + + Shows + + + This period + + + Item + + + Change in % + + + in % + + + Cost composition + + + Nothing was billed in this period. + + + Feed-in credit + + + Manual costs + + + Standing charges + + + Metered usage + + + Coverage and freshness + + + Data available: {0} + + + What happened in the selected period, what changed, and where to look next. + + + Hide table + + + History + + + Last reading or event: {0} + + + Latest month with data: {0} ({1}) + + + {0} (for {1}) + + + ¹ Compared over the part both periods cover. + + + over the part both periods cover + + + Meters: + + + {0} historical + + + {0} live + + + {0} without data + + + {0} stale + + + Open in Analysis + + + Analyse {0} + + + This period: {0} + + + Getting started + + + Share + + + Show as table + + + Share as a bar + + + A credit is larger than its charges, so the composition is shown as bars around zero instead of a donut. + + + Total cost + + + No meter counts towards this energy type's totals yet. + + + Look at an energy type: + + + No meters yet: + + + prices meters outside the bill + + + overlaps {0} + + + Overlapping views + + + These categories share meters, manual costs or standing charges with another category, or price something the bill does not. They are listed for reference and never added up. + diff --git a/src/App/MeterDetails/MeterAnalysisLoader.cs b/src/App/MeterDetails/MeterAnalysisLoader.cs new file mode 100644 index 0000000..bddd48f --- /dev/null +++ b/src/App/MeterDetails/MeterAnalysisLoader.cs @@ -0,0 +1,362 @@ +using MeterVault.App.Analysis; +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; +using MeterVault.Infrastructure.Dashboard; +using MeterVault.Infrastructure.Options; + +namespace MeterVault.App.MeterDetails; + +/// +/// One committed answer of the meter page's Analysis tab (brief §7.2, D-46): the resolved period, the meter's series from +/// the shared reader, its cost by its rule (and the cost of the comparison period), the chart and table inputs built in +/// the request's culture, a projection when D-09 allows one, and the events and tariff changes inside the range — all +/// for , so a page that moved on to another meter can tell a late answer from its own. +/// +/// The meter the answer is for. +/// The analysis state it answers (scope forced to the meter). +/// The resolved period. +/// The reader's result. +/// The meter's series; null when the reader does not know the meter. +/// The meter's cost by its rule; null when not read (pending, refused, unknown meter). +/// The cost in the comparison period's paired buckets; null without a comparison. +/// The chart series (the metric's series and its overlay). +/// The table series. +/// The straight-line projection of a to-date period, when D-09 allows one. +/// Events and tariff changes inside the range. +/// +/// Every meter the answer speaks of, by id — the meter, its sources at any depth, and any meter a value or problem names +/// that the result itself does not (the other end of a dependency loop) — so no figure or attention item says "#id". +/// +public sealed record MeterAnalysisView( + int MeterId, + AnalysisQuery Query, + ResolvedPeriod Period, + AnalysisResult Quantities, + AnalysisSeries? Series, + CostAnalysis? Cost, + CostAnalysis? CostComparison, + IReadOnlyList Chart, + IReadOnlyList Table, + MeterProjection? Projection, + MeterMarkers Markers, + IReadOnlyDictionary MeterNames) +{ + /// The names the attention list speaks of (). + public AttentionNames AttentionNames => new(MeterNames); + + /// True when the meter has a cost rule that prices something (not ). + public bool IsCosted => Cost?.Meter is { Rule: not MeterCostRule.None }; + + /// True when the chart shows the cost rather than the quantity (metric=cost on a costed meter). + public bool ShowsCost => IsCosted && Query.Metric == AnalysisMetric.Cost; + + /// + /// The change of the cost against the comparison period, by the rule every page states it with (D-07, + /// ): the totals when both are complete, else the paired buckets complete on + /// both sides — a period whose data ends in May is not set against a whole year — else not comparable. + /// + public CostChange CostChange => + Cost is { } current && CostComparison is { } previous && Quantities.Comparison is { IsApplicable: true } comparison + ? OverviewComparison.Between(current.Buckets, current.Total, previous.Buckets, previous.Total, comparison.Buckets) + : CostChange.NoComparison; + + /// The metric the meter's quantity is charted under; null for an indicator. + public AnalysisMetric? QuantityMetric => Series is { } s ? AnalysisMetrics.MetricOf(s.Kind) : null; + + /// The metrics the toolbar offers: the quantity, and the cost when the meter is costed. + public IReadOnlyList Metrics => + IsCosted && QuantityMetric is { } quantity ? [quantity, AnalysisMetric.Cost] : []; +} + +/// +/// Loads the meter page's Analysis tab (brief §7.2): resolves the period once, reads the meter's series with its +/// comparison through — a virtual meter from its formula, like any page — prices it with +/// by the meter's rule, and builds the chart and table inputs inside the load, so the page +/// commits one coherent value (LoadSequencer pattern). +/// +public sealed class MeterAnalysisLoader(AnalysisPeriods periods, AnalysisReader reader, CostReader costs, MeterDetailService details) +{ + /// The most events listed as context under the chart. + public const int MaxMarkers = 12; + + private readonly AnalysisPeriods _periods = periods; + private readonly AnalysisReader _reader = reader; + private readonly CostReader _costs = costs; + private readonly MeterDetailService _details = details; + + /// The page defaults of a meter page: the history defaults with the meter as the route's scope (D-02). + public static AnalysisDefaults DefaultsFor(int meterId) => AnalysisDefaults.History.ForScope(QueryScope.ForMeter(meterId)); + + /// The query as the meter page reads it: its scope is always the meter (the route names it). + public static AnalysisQuery ForMeter(AnalysisQuery query, int meterId) + { + ArgumentNullException.ThrowIfNull(query); + + var scope = QueryScope.ForMeter(meterId); + return query.Scope.Equals(scope) ? query : query.WithScope(scope); + } + + /// Loads the analysis of for as of . + public async Task LoadAsync(int meterId, AnalysisQuery query, DateTimeOffset now, CancellationToken cancellationToken = default) + { + query = ForMeter(query, meterId); + var period = await _periods.ResolveAsync(query, now, cancellationToken); + var quantities = await _reader.ReadAsync(query.ToAnalysisRequest(period)!, cancellationToken); + var series = quantities.SeriesFor(meterId); + + CostAnalysis? cost = null; + CostAnalysis? costComparison = null; + if (series is not null && quantities.Refusal == AnalysisRefusal.None && !series.IsPending && !period.HasNoHistory()) + { + cost = await _costs.ReadAsync(query.ToCostRequests(period, quantities.Plan)[0], cancellationToken); + if (cost.Refusal == CostRefusal.None && cost.Meter is { Rule: not MeterCostRule.None } && query.Comparison.Kind != ComparisonKind.None) + { + var comparison = query.ToCostComparison(cost.Request, quantities.Plan); + if (comparison.Request is { } request) + { + costComparison = await _costs.ReadAsync(request, cancellationToken); + } + } + } + + var markers = period.HasNoHistory() + ? MeterMarkers.None + : await _details.GetMarkersAsync( + meterId, MeterRecordRange.Of(period), period.FirstDay, period.NominalLastDay(), MaxMarkers, cancellationToken); + + var projection = series is null ? null : MeterProjection.For(series, period); + var names = await NamesAsync(quantities, series, cost, cancellationToken); + var (chart, table) = Build(query, series, cost, costComparison, id => names.GetValueOrDefault(id)); + return new MeterAnalysisView(meterId, query, period, quantities, series, cost, costComparison, chart, table, projection, markers, names); + } + + /// + /// The names the tab speaks of: what the results carry (the meter, its sources at any depth, priced lines), plus the + /// names of any other meter a value or a problem points at — a loop stops the evaluation, so its other end is in no + /// contribution — read by id. + /// + private async Task> NamesAsync( + AnalysisResult quantities, AnalysisSeries? series, CostAnalysis? cost, CancellationToken cancellationToken) + { + var names = new Dictionary(); + void Add(int id, string? name) + { + if (!string.IsNullOrWhiteSpace(name)) + { + names.TryAdd(id, name); + } + } + + foreach (var item in quantities.Series) + { + if (item.MeterId is { } id) + { + Add(id, item.Name); + } + + AddContributions(item.Contributions); + } + + foreach (var entry in quantities.Classification) + { + Add(entry.MeterId, entry.Name); + } + + foreach (var line in cost?.Lines ?? []) + { + Add(line.MeterId, line.Name); + } + + var wanted = new HashSet(); + foreach (var problem in quantities.Problems.Concat(cost?.QuantityProblems ?? [])) + { + if (problem.MeterId is { } id) + { + wanted.Add(id); + } + + wanted.UnionWith(problem.MeterIds); + wanted.UnionWith(problem.Virtual?.MeterIds ?? []); + if (problem.Totals?.OtherMeterId is { } other) + { + wanted.Add(other); + } + + if (problem.Hint is { } hint) + { + wanted.Add(hint.MeterId); + wanted.Add(hint.OtherMeterId); + } + } + + foreach (var attention in cost?.Attention ?? []) + { + if (attention.MeterId is { } id) + { + wanted.Add(id); + } + + if (attention.Price?.MeterId is { } priced) + { + wanted.Add(priced); + } + } + + if (series is not null) + { + foreach (var value in series.Values.Append(series.Total) + .Concat(series.Comparison is { } comparison ? comparison.Values.Append(comparison.Total) : [])) + { + wanted.UnionWith(value.DependencyPath ?? []); + } + } + + wanted.ExceptWith(names.Keys); + if (wanted.Count > 0) + { + foreach (var (id, name) in await _details.GetMeterNamesAsync(wanted, cancellationToken)) + { + Add(id, name); + } + } + + return names; + + void AddContributions(IReadOnlyList contributions) + { + foreach (var contribution in contributions) + { + Add(contribution.MeterId, contribution.Name); + AddContributions(contribution.Nested); + } + } + } + + /// + /// The chart and table inputs, in the current culture: the quantity (bars) with its comparison (a line) and its cost + /// per bucket, or — with metric=cost on a costed meter — the cost and the comparison priced in the paired + /// buckets. + /// + /// The analysis state. + /// The meter's series. + /// Its cost by its rule. + /// The cost in the comparison's paired buckets. + /// + /// Names the meter a derived value misses ("partial — Zähler Solar 2"); the names the series' contributions carry + /// () without it. + /// + public static (IReadOnlyList Chart, IReadOnlyList Table) Build( + AnalysisQuery query, AnalysisSeries? series, CostAnalysis? cost, CostAnalysis? costComparison, Func? meterName = null) + { + ArgumentNullException.ThrowIfNull(query); + + if (series is null) + { + return ([], []); + } + + var name = AnalysisChartSeries.NameOf(series); + var costed = cost?.Meter is { Rule: not MeterCostRule.None }; + if (costed && query.Metric == AnalysisMetric.Cost) + { + var key = series.Key.Id + ":cost"; + var costName = Loc.F(Strings.MeterDetail_CostOf, name); + List costChart = [AnalysisChartSeries.ForCost(key, costName, cost!.Currency, cost.Buckets)]; + var costTable = AnalysisTableSeries.ForCosts(key, costName, cost.Currency, cost.Buckets, cost.Total); + if (costComparison is not null) + { + costChart.Add(AnalysisChartSeries.ComparisonForCost( + key, AnalysisChartSeries.ComparisonName(costName, query.Comparison), cost.Currency, costComparison.Buckets)); + costTable = costTable.WithComparisonCosts(costComparison.Buckets, costComparison.Total, cost.Currency); + } + + return (costChart, [costTable]); + } + + // A derived value names the source it misses ("partial — Zähler Solar 2"). + var nameOf = meterName ?? NamesOf(series); + List chart = [AnalysisChartSeries.ForSeries(series, name, meterName: nameOf)]; + if (AnalysisChartSeries.ComparisonOf(series, AnalysisChartSeries.ComparisonName(name, query.Comparison), meterName: nameOf) is { } overlay) + { + chart.Add(overlay); + } + + var table = AnalysisTableSeries.ForSeries(series, name, nameOf); + if (costed) + { + table = table.WithCosts(cost!.Buckets, cost.Total, cost.Currency); + } + + return (chart, [table]); + } + + /// The names of the meter and every source of its calculation, at any depth, by id. + public static Func NamesOf(AnalysisSeries series) + { + ArgumentNullException.ThrowIfNull(series); + + var names = new Dictionary(); + if (series.MeterId is { } id && series.Name.Length > 0) + { + names[id] = series.Name; + } + + void Add(IReadOnlyList contributions) + { + foreach (var contribution in contributions) + { + if (contribution.Name.Length > 0) + { + names.TryAdd(contribution.MeterId, contribution.Name); + } + + Add(contribution.Nested); + } + } + + Add(series.Contributions); + return meterId => names.GetValueOrDefault(meterId); + } +} + +/// The date filter of the meter page's record tabs, from the page period (D-50). +public static class MeterRecordRange +{ + /// + /// The records of : from its first local midnight to the end of the range it names — a + /// to-date period includes rows stamped later in its month or year, which are exactly the "recorded after now" rows + /// the analysis leaves out — and everything for all history, whose records include any stamped in the future. + /// + public static RecordRange Of(ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(period); + + return period.Preset == PeriodPreset.AllHistory + ? RecordRange.All + : new RecordRange(InstanceTimeZone.StartOf(period.FirstDay, period.Zone), period.NominalEnd()); + } + + /// + /// The dates the record tabs list, for their toolbar: the whole named range (the end of the month for month to date), + /// since rows dated after now are listed too — not the analysis's "up to today". All history shows its data's dates. + /// + public static string Text(ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(period); + + return period.Preset == PeriodPreset.AllHistory || period.HasNoHistory() + ? Format.PeriodRange(period) + : Format.DateRange(period.FirstDay, period.NominalLastDay()); + } + + /// True for a record dated after the instant the page read "now" at (D-04): listed, and marked as such. + public static bool IsAfterNow(DateTimeOffset time, ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(period); + + return time > period.Now; + } +} diff --git a/src/App/MeterDetails/MeterDrill.cs b/src/App/MeterDetails/MeterDrill.cs new file mode 100644 index 0000000..f5b0ca2 --- /dev/null +++ b/src/App/MeterDetails/MeterDrill.cs @@ -0,0 +1,46 @@ +using MeterVault.App.Analysis; +using MeterVault.Core.Analysis; + +namespace MeterVault.App.MeterDetails; + +/// +/// Where a bucket of the meter page's analysis leads (D-51, brief §3.2 "Explain a spike"): the same analysis over the +/// bucket in the next finer size its data resolves; when there is none, a physical meter's records of the bucket; a +/// virtual meter — which stores no records — its own analysis over just that bucket, where the sources' contributions +/// link on to each source's records for it. Nothing when the page already shows exactly that bucket. +/// +public static class MeterDrill +{ + /// The link of ; null when it leads nowhere (the page then offers no click). + /// The meter. + /// True for a virtual meter. + /// The page's analysis state. + /// The period the page shows. + /// The bucket. + /// The coarsest resolution of the charted data (). + public static string? Href( + int meterId, bool isVirtual, AnalysisQuery query, ResolvedPeriod shown, AnalysisBucket bucket, ResolutionClass? resolution) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(shown); + ArgumentNullException.ThrowIfNull(bucket); + + if (AnalysisNavigation.DrillInto(query, bucket, resolution) is { } next) + { + return MeterLinks.Analysis(meterId, next); + } + + if (!isVirtual) + { + return AnalysisNavigation.NormalizedData(meterId, query, bucket); + } + + var (first, last) = AnalysisNavigation.DaysOf(bucket); + if (!PeriodResolver.IsValidCustomRange(first, last) || (shown.FirstDay == first && shown.NominalLastDay() == last)) + { + return null; + } + + return MeterLinks.Analysis(meterId, query.WithCustomRange(first, last)); + } +} diff --git a/src/App/MeterDetails/MeterProjection.cs b/src/App/MeterDetails/MeterProjection.cs new file mode 100644 index 0000000..e65cb41 --- /dev/null +++ b/src/App/MeterDetails/MeterProjection.cs @@ -0,0 +1,80 @@ +using MeterVault.Core.Analysis; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.App.MeterDetails; + +/// +/// The straight-line projection of a meter's to-date quantity (D-09): the actual so far plus the covered rate over the +/// days left in the month or year — kept apart from the actual and labelled with its method, never compared by a change +/// chip. +/// +/// The covered days the rate is taken from. +/// The projected total for the whole month or year. +/// Its unit (the series' normalized unit). +public sealed record MeterProjection(int Days, double Value, string Unit) +{ + /// + /// The projection of over , or null when D-09 suppresses it: the + /// period is not a month or year to date; the total is unknown, an opening balance, not additive or an indicator; + /// the data is coarser than the period (monthly data cannot project a month); less than 7 (month) or 30 (year) days + /// are covered; or the coverage ended more than two of the meter's typical intervals before now — a lone old monthly + /// reading is not live data. + /// + public static MeterProjection? For(AnalysisSeries series, ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(series); + ArgumentNullException.ThrowIfNull(period); + + if (!period.IsToDate || period.Preset is not (PeriodPreset.MonthToDate or PeriodPreset.YearToDate)) + { + return null; + } + + if (series.Total is not { Value: { } actual, Status: BucketStatus.Available or BucketStatus.Partial } total + || !double.IsFinite(actual) + || total.Provenance.HasFlag(Provenance.OpeningBalance) + || !series.IsAdditive + || series.Kind == QuantityKind.Indicator) + { + return null; + } + + var isMonth = period.Preset == PeriodPreset.MonthToDate; + if (series.Resolution is not { } resolution || resolution > (isMonth ? ResolutionClass.Week : ResolutionClass.Month)) + { + return null; + } + + if (series.Availability is not { } available || TypicalInterval(resolution) is not { } typical) + { + return null; + } + + var coveredFrom = available.From > period.From ? available.From : period.From; + var coveredTo = available.To < period.Now ? available.To : period.Now; + var coveredDays = (coveredTo - coveredFrom).TotalDays; + if (coveredDays < (isMonth ? 7 : 30) || period.Now - coveredTo > typical * 2) + { + return null; + } + + if (period.NominalEnd() is not { } end || end <= period.Now) + { + return null; + } + + var remainingDays = (end - period.Now).TotalDays; + var projected = actual + (actual / coveredDays * remainingDays); + return double.IsFinite(projected) ? new MeterProjection((int)Math.Round(coveredDays), projected, series.Unit) : null; + } + + /// The longest interval a resolution class stands for; null for data coarser than a month. + private static TimeSpan? TypicalInterval(ResolutionClass resolution) => resolution switch + { + ResolutionClass.Hour => TimeSpan.FromHours(1), + ResolutionClass.Day => TimeSpan.FromDays(1), + ResolutionClass.Week => TimeSpan.FromDays(7), + ResolutionClass.Month => TimeSpan.FromDays(31), + _ => null, + }; +} diff --git a/src/App/MeterDetails/ReadingEntryVerdict.cs b/src/App/MeterDetails/ReadingEntryVerdict.cs new file mode 100644 index 0000000..9abf71c --- /dev/null +++ b/src/App/MeterDetails/ReadingEntryVerdict.cs @@ -0,0 +1,58 @@ +using MeterVault.Infrastructure.Dashboard; + +namespace MeterVault.App.MeterDetails; + +/// +/// What the manual-reading dialog says about an entry while it is typed (D-50): whether it is backdated or in the future, +/// whether the register guard would refuse it, how far it moved since the reading before it, and whether it replaces a +/// stored reading — judged against a read for exactly the entered instant, never +/// against a page of rows. A context for another instant (the pickers moved, its query is still out) gives no verdict +/// rather than a wrong one. +/// +/// The instant lies before the latest reading: consumption from there on is recomputed. +/// The instant lies more than a minute after now. +/// A register that only counts up would refuse the value: it is below the previous reading and no swap or reset explains it. +/// The value less the previous reading; null when either is unknown or the value would be refused. +/// The reading the value is compared with. +/// A reading is stored at exactly this instant; saving replaces its value. +/// That reading is a new register's start value written with a swap or reset. +public sealed record ReadingEntryVerdict( + bool IsBackdated, + bool IsFuture, + bool WouldBeRejected, + double? ChangeSincePrevious, + RegisterPoint? Previous, + bool ReplacesReading, + bool ReplacesRegisterStart) +{ + /// A minute of slack, so "now" never trips the future warning on a slow round trip. + public static readonly TimeSpan FutureSlack = TimeSpan.FromMinutes(1); + + /// Judges at against . + /// The context read for the entered instant; null while none is loaded. + /// The parsed value; null while the text is not a number. + /// The entered instant (UTC); null while incomplete or in a skipped hour. + /// The current instant. + public static ReadingEntryVerdict Of(ReadingEntryContext? context, double? value, DateTimeOffset? entered, DateTimeOffset now) + { + var future = entered is { } instant && instant > now + FutureSlack; + if (context is null || entered is not { } at || context.At != at.ToUniversalTime()) + { + return new ReadingEntryVerdict(false, future, false, null, null, false, false); + } + + var backdated = context.Latest is { } latest && at < latest.Time; + var previous = context.Previous; + var rejected = context.Monotonic && !context.BoundaryExplainsDecrease + && value is { } v && previous is { } p && v < p.Value; + double? change = !rejected && value is { } typed && previous is { } before ? typed - before.Value : null; + return new ReadingEntryVerdict( + backdated, + future, + rejected, + change, + previous, + context.AtTime is not null, + context.AtTime is { IsRegisterStart: true }); + } +} diff --git a/src/App/MeterDetails/RecordPager.cs b/src/App/MeterDetails/RecordPager.cs new file mode 100644 index 0000000..d682630 --- /dev/null +++ b/src/App/MeterDetails/RecordPager.cs @@ -0,0 +1,49 @@ +using MeterVault.Infrastructure.Dashboard; + +namespace MeterVault.App.MeterDetails; + +/// +/// Where a record tab is in its keyset pages (D-50): the start cursor of every page visited, newest first, so "Newer" +/// returns to exactly the page it came from and "Newest" to the top — without an offset, which would shift as rows +/// arrive. +/// +public sealed class RecordPager +{ + private readonly List _starts = [null]; + + /// The page shown, from 0 (the newest rows). + public int PageIndex => _starts.Count - 1; + + /// Where the page shown starts; null for the newest rows. + public RecordCursor? Current => _starts[^1]; + + /// True when a newer page exists (the page shown is not the first). + public bool CanGoNewer => _starts.Count > 1; + + /// The 1-based number of the first row shown. + public int FirstRow => (PageIndex * MeterDetailService.PageSize) + 1; + + /// Back to the newest rows (a new filter, new data). + public void Reset() + { + _starts.Clear(); + _starts.Add(null); + } + + /// To the older page starting at (the page's ). + public void Older(RecordCursor next) + { + ArgumentNullException.ThrowIfNull(next); + + _starts.Add(next); + } + + /// Back to the newer page. + public void Newer() + { + if (_starts.Count > 1) + { + _starts.RemoveAt(_starts.Count - 1); + } + } +} diff --git a/src/App/MeterEditing/CalculationDraft.cs b/src/App/MeterEditing/CalculationDraft.cs new file mode 100644 index 0000000..0433e58 --- /dev/null +++ b/src/App/MeterEditing/CalculationDraft.cs @@ -0,0 +1,244 @@ +using System.Globalization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Virtual; + +namespace MeterVault.App.MeterEditing; + +/// How the meter editor lets a virtual meter's calculation be written (D-31). +public enum CalculationMode +{ + /// Pick meters to add up: m4 + m5. + Sum, + + /// Pick a meter to start from and meters to subtract: m1 - m3. + Difference, + + /// Type any formula of the grammar (D-26). + Advanced, +} + +/// +/// A virtual meter's calculation as the meter editor holds it while it is edited (D-25, D-31): the mode it is written +/// in, the meters picked for a sum or a difference, the formula typed in advanced mode, and the declared result kind, +/// unit and cost rule — each null while left to inference (A-08 writes the effective values on save). +/// +/// +/// The formula is the only authority: is what is stored, and the pickers are just a way to write the +/// two commonest shapes. Opening a stored definition picks the mode its formula has (), so a +/// saved sum reopens as a sum and anything else as a formula. +/// +public sealed class CalculationDraft +{ + private readonly List _sum = []; + private readonly List _subtrahends = []; + + /// The mode the calculation is written in. + public CalculationMode Mode { get; private set; } = CalculationMode.Sum; + + /// The meters of a sum, in formula order. + public IReadOnlyList SumSources => _sum; + + /// The meter a difference starts from. + public int? Minuend { get; private set; } + + /// The meters a difference subtracts, in formula order. + public IReadOnlyList Subtrahends => _subtrahends; + + /// The formula typed in advanced mode. + public string Expression { get; set; } = string.Empty; + + /// The declared result kind; null = inferred from the sources. + public QuantityKind? ResultKind { get; set; } + + /// The declared result unit; blank = the sources' unit. + public string? ResultUnit { get; set; } + + /// The chosen cost rule; null = the default for the formula (D-39, A-15). + public VirtualCostRule? CostRule { get; set; } + + /// The formula text the current mode writes; empty while nothing is picked. + public string Text => Mode switch + { + CalculationMode.Sum => string.Join(" + ", _sum.Select(Token)), + CalculationMode.Difference => Minuend is { } minuend + ? string.Join(" - ", _subtrahends.Where(id => id != minuend).Prepend(minuend).Select(Token)) + : string.Empty, + _ => Expression.Trim(), + }; + + /// + /// True when the current mode has nothing to calculate yet: no meter picked for a sum, no meter to subtract in a + /// difference, or an empty formula. The editor asks for input then instead of reporting errors. + /// + public bool IsIncomplete => Mode switch + { + CalculationMode.Sum => _sum.Count == 0, + CalculationMode.Difference => Minuend is null || _subtrahends.All(id => id == Minuend), + _ => string.IsNullOrWhiteSpace(Expression), + }; + + /// The definition the draft stands for, with undeclared parts left to inference. + public VirtualDefinition Definition => new( + Text, + ResultKind, + string.IsNullOrWhiteSpace(ResultUnit) ? null : ResultUnit.Trim(), + CostRule); + + /// + /// A draft for a stored (or derived) definition: its declared kind, unit and cost rule, and the mode its formula's + /// shape allows. Null gives an empty sum. + /// + public static CalculationDraft From(VirtualDefinition? definition) + { + var draft = new CalculationDraft(); + if (definition is null) + { + return draft; + } + + draft.ResultKind = definition.ResultKind; + draft.ResultUnit = definition.ResultUnit; + draft.CostRule = definition.CostRule; + draft.Expression = definition.Expression; + draft.Mode = CalculationMode.Advanced; + if (definition.Formula is { } formula) + { + draft.Adopt(formula); + } + + return draft; + } + + /// + /// The mode a formula can be edited in: a sum when it is exactly ma + mb + …, a difference when it is exactly + /// ma - mb - … (each meter once, no constant, no parentheses that change it), otherwise advanced. The meters + /// come out in formula order. + /// + public static CalculationMode ShapeOf(Formula formula, out IReadOnlyList meterIds) + { + ArgumentNullException.ThrowIfNull(formula); + + var references = formula.References.Select(r => r.MeterId).ToList(); + meterIds = references; + if (references.Count == 0 || references.Distinct().Count() != references.Count) + { + return CalculationMode.Advanced; + } + + if (formula.Equals(Formula.Sum(references))) + { + return CalculationMode.Sum; + } + + return references.Count > 1 && formula.Equals(Formula.Difference(references[0], references.Skip(1))) + ? CalculationMode.Difference + : CalculationMode.Advanced; + } + + /// + /// Switches the mode. The formula written so far travels to advanced mode as text; from advanced mode a formula of the + /// target's shape is taken over as it is, and any other formula seeds the pickers with the meters it refers to. + /// + public void SwitchTo(CalculationMode mode) + { + if (mode == Mode) + { + return; + } + + if (mode == CalculationMode.Advanced) + { + Expression = Text; + Mode = mode; + return; + } + + if (Mode == CalculationMode.Advanced) + { + var referenced = FormulaParser.Parse(Expression) is { Success: true } parsed + ? parsed.Formula.References.Select(r => r.MeterId).Distinct().ToList() + : [.. FormulaParser.ScanMeterIds(Expression)]; + // The formula's own order, not what the pickers held before it was typed. + _sum.Clear(); + _subtrahends.Clear(); + if (mode == CalculationMode.Sum) + { + SetSumSources(referenced); + } + else + { + SetMinuend(referenced.Count > 0 ? referenced[0] : null); + SetSubtrahends(referenced.Skip(1)); + } + } + else if (mode == CalculationMode.Sum) + { + // Difference → sum: the same meters, now added. + SetSumSources(Minuend is { } minuend ? _subtrahends.Prepend(minuend) : _subtrahends); + } + else + { + // Sum → difference: the first meter is the one to start from. + SetMinuend(_sum.Count > 0 ? _sum[0] : null); + SetSubtrahends(_sum.Skip(1)); + } + + Mode = mode; + } + + /// + /// Sets the meters of a sum. Meters already in the sum keep their place and new ones are appended in the order given, + /// so picking a meter never reorders what was there. + /// + public void SetSumSources(IEnumerable meterIds) => Merge(_sum, meterIds); + + /// Sets the meter a difference starts from; it is never also subtracted. + public void SetMinuend(int? meterId) + { + Minuend = meterId; + if (meterId is { } id) + { + _subtrahends.Remove(id); + } + } + + /// Sets the meters a difference subtracts, keeping the order of those already picked. + public void SetSubtrahends(IEnumerable meterIds) => Merge(_subtrahends, meterIds.Where(id => id != Minuend)); + + /// Appends m<id> to the typed formula, with a + when it does not end in an operator. + public void InsertReference(int meterId) + { + var text = Expression.TrimEnd(); + var token = Token(meterId); + Expression = text.Length == 0 || text[^1] is '+' or '-' or '*' or '/' or '(' + ? (text.Length == 0 ? token : text + " " + token) + : text + " + " + token; + } + + /// The formula token of a meter. + public static string Token(int meterId) => "m" + meterId.ToString(CultureInfo.InvariantCulture); + + private void Adopt(Formula formula) + { + var mode = ShapeOf(formula, out var ids); + switch (mode) + { + case CalculationMode.Sum: + SetSumSources(ids); + break; + case CalculationMode.Difference: + SetMinuend(ids[0]); + SetSubtrahends(ids.Skip(1)); + break; + } + + Mode = mode; + } + + private static void Merge(List target, IEnumerable meterIds) + { + var wanted = meterIds.Distinct().ToList(); + target.RemoveAll(id => !wanted.Contains(id)); + target.AddRange(wanted.Where(id => !target.Contains(id))); + } +} diff --git a/src/App/MeterEditing/CalculationSources.cs b/src/App/MeterEditing/CalculationSources.cs new file mode 100644 index 0000000..c7e7c81 --- /dev/null +++ b/src/App/MeterEditing/CalculationSources.cs @@ -0,0 +1,217 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.App.MeterEditing; + +/// +/// A meter the calculation editor may offer as a source (D-31): its name, what its normalized series measures and in +/// which unit (D-20) — never its raw register unit — and its service period (D-24), which decides where it counts as a +/// known zero. +/// +/// The meter. +/// Its name (user data). +/// What its values measure; a virtual meter's effective result kind. +/// Its normalized unit (canonical). +/// A calculation itself. +/// Its energy type. +/// Start of its service period, when set. +/// End of its service period, when set. +/// +/// False for a virtual meter without a usable calculation (not set up, unreadable, invalid): a formula over it cannot be +/// evaluated, so the pickers leave it out. +/// +public sealed record SourceOption( + int MeterId, + string Name, + QuantityKind Kind, + string Unit, + bool IsVirtual, + int EnergyTypeId, + DateOnly? InstalledAt, + DateOnly? RetiredAt, + bool IsEvaluable) +{ + /// A sum or a difference may take it: evaluable and additive (an indicator is never added up, D-26). + public bool IsAddable => IsEvaluable && Kind is not (QuantityKind.Indicator or QuantityKind.Cost); +} + +/// Which meters the calculation editor offers, and which of them fit together (D-26, D-31). +public static class CalculationSources +{ + /// + /// Every meter a calculation of may refer to: all meters but the draft itself and those + /// whose calculation reads it (they would close a loop). Meters of come first, then by + /// name. + /// + public static IReadOnlyList For(AnalysisCatalog catalog, int draftId, int energyTypeId) + { + ArgumentNullException.ThrowIfNull(catalog); + + var dependents = draftId > 0 ? catalog.Graph.Dependents(draftId).ToHashSet() : []; + return [.. catalog.Meters.Values + .Where(m => m.Id != draftId && !dependents.Contains(m.Id)) + .Select(m => new SourceOption( + m.Id, + m.Name, + m.Quantity.Kind, + Units.Normalize(m.Quantity.Unit), + m.IsVirtual, + m.EnergyTypeId, + m.Meter.InstalledAt, + m.Meter.RetiredAt, + !m.IsVirtual || m.Formula is not null)) + .OrderBy(o => o.EnergyTypeId == energyTypeId ? 0 : 1) + .ThenBy(o => o.Name, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(o => o.MeterId)]; + } + + /// + /// The meters a sum may add next to (D-26: + needs one unit and one kind): every + /// addable meter while nothing is picked, then only those measuring the same kind in the same unit as the first pick. + /// What is already picked stays listed, so it can be taken out again. + /// + public static IReadOnlyList ForSum(IReadOnlyList options, IReadOnlyList selected) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(selected); + + var first = selected.Select(id => options.FirstOrDefault(o => o.MeterId == id)).OfType().FirstOrDefault(); + return [.. options.Where(o => selected.Contains(o.MeterId) + || (o.IsAddable && (first is null || (o.Kind == first.Kind && Units.AreSame(o.Unit, first.Unit)))))]; + } + + /// The meters a difference may start from: any addable meter (and the current pick). + public static IReadOnlyList ForMinuend(IReadOnlyList options, int? selected) + { + ArgumentNullException.ThrowIfNull(options); + + return [.. options.Where(o => o.MeterId == selected || o.IsAddable)]; + } + + /// + /// The meters a difference may subtract from : addable meters in its unit (a difference of + /// two kinds — import minus export — is allowed as a declared net balance). What is already picked stays listed. + /// + public static IReadOnlyList ForSubtrahends(IReadOnlyList options, int? minuend, IReadOnlyList selected) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(selected); + + var start = options.FirstOrDefault(o => o.MeterId == minuend); + return [.. options.Where(o => o.MeterId != minuend + && (selected.Contains(o.MeterId) || (o.IsAddable && (start is null || Units.AreSame(o.Unit, start.Unit)))))]; + } + + /// + /// True when the picked meters measure more than one kind — a difference of import and export, say. Such a + /// calculation needs its result declared (usually net) before it can be saved. + /// + public static bool MixesKinds(IReadOnlyList options, IEnumerable picked) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(picked); + + return picked.Select(id => options.FirstOrDefault(o => o.MeterId == id)?.Kind).OfType().Distinct().Skip(1).Any(); + } + + /// + /// The cost rules a calculation may choose (D-39, A-15), first: + /// + /// the sources' own costs only for a plain sum — all the way down through nested sums — that is not generation + /// (generation is never billed, so its sources cost nothing); + /// its own quantity priced only for a linear formula (no constant, product or ratio); + /// nothing but none for an indicator, or anything built from one. + /// + /// + /// The parsed formula; null (a syntax error) offers none only. + /// The effective result kind; null while it must still be chosen. + /// The meter being edited. + /// The meters the formula refers to. + public static IReadOnlyList OfferedCostRules(Formula? formula, QuantityKind? kind, int draftId, MeterCatalog catalog) + { + ArgumentNullException.ThrowIfNull(catalog); + + List rules = [VirtualCostRule.None]; + if (formula is null || kind == QuantityKind.Indicator || OverIndicator(formula, draftId, catalog)) + { + return rules; + } + + if (formula.IsPureSum && kind != QuantityKind.Generation && VirtualValidator.NestedNonSum(formula, draftId, catalog) is null) + { + rules.Add(VirtualCostRule.SourceCosts); + } + + if (formula.IsLinear) + { + rules.Add(VirtualCostRule.OwnQuantity); + } + + return rules; + } + + /// Why a cost rule is not offered for this formula; null when it is. + public static CostRuleUnavailable? WhyNot(VirtualCostRule rule, Formula? formula, QuantityKind? kind, int draftId, MeterCatalog catalog) + { + ArgumentNullException.ThrowIfNull(catalog); + + if (rule == VirtualCostRule.None || OfferedCostRules(formula, kind, draftId, catalog).Contains(rule)) + { + return null; + } + + if (formula is null) + { + return new CostRuleUnavailable(CostRuleBlock.NoFormula, null); + } + + if (kind == QuantityKind.Indicator || OverIndicator(formula, draftId, catalog)) + { + return new CostRuleUnavailable(CostRuleBlock.Indicator, null); + } + + if (rule == VirtualCostRule.OwnQuantity) + { + return new CostRuleUnavailable(CostRuleBlock.NotLinear, null); + } + + if (!formula.IsPureSum) + { + return new CostRuleUnavailable(CostRuleBlock.NotPureSum, null); + } + + return kind == QuantityKind.Generation + ? new CostRuleUnavailable(CostRuleBlock.Generation, null) + : new CostRuleUnavailable(CostRuleBlock.NestedNotPureSum, VirtualValidator.NestedNonSum(formula, draftId, catalog)); + } + + private static bool OverIndicator(Formula formula, int draftId, MeterCatalog catalog) => + formula.MeterIds.Any(id => id != draftId && catalog.Find(id)?.Kind == QuantityKind.Indicator); +} + +/// Why a cost rule cannot be chosen for a calculation. +public enum CostRuleBlock +{ + /// The formula does not parse yet. + NoFormula, + + /// An indicator is never costed. + Indicator, + + /// The sources' costs add up only for a plain sum. + NotPureSum, + + /// A nested source is not a plain sum (). + NestedNotPureSum, + + /// Generation is never billed, so a generation sum's sources cost nothing. + Generation, + + /// Only a linear formula can be priced as a quantity. + NotLinear, +} + +/// A cost rule that is not offered, and the meter the reason is about (a nested calculation), if any. +public sealed record CostRuleUnavailable(CostRuleBlock Reason, int? MeterId); diff --git a/src/App/MeterEditing/MeterEditorText.cs b/src/App/MeterEditing/MeterEditorText.cs new file mode 100644 index 0000000..0e5c7a1 --- /dev/null +++ b/src/App/MeterEditing/MeterEditorText.cs @@ -0,0 +1,154 @@ +using System.Globalization; +using MeterVault.App.Localization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Analysis.Virtual; + +namespace MeterVault.App.MeterEditing; + +/// +/// The meter editor's findings in words (D-26, D-23, D-28): validation problems of a calculation, a refused totals +/// override and the reason a legacy meter's links imply no sum — each naming the meters involved, never "m12" alone. +/// +/// +/// The Core findings are data (kinds, meter ids, unit and kind tokens). These sentences are the editor's; short labels +/// of the same enums for attention items live with . +/// +public static class MeterEditorText +{ + /// One validation problem as a sentence; names a meter id. + public static string Problem(VirtualProblem problem, Func name) + { + ArgumentNullException.ThrowIfNull(problem); + ArgumentNullException.ThrowIfNull(name); + + string Meter(int index) => index < problem.MeterIds.Count ? name(problem.MeterIds[index]) : Strings.MeterEditor_AnotherMeter; + string Value(int index) => index < problem.Values.Count ? problem.Values[index] : string.Empty; + string Names() => string.Join(", ", problem.MeterIds.Select(name)); + + return problem.Kind switch + { + VirtualProblemKind.Syntax when problem.SyntaxError is { } error => FormulaError(error), + VirtualProblemKind.Syntax => Strings.MeterEditor_FormulaError_UnexpectedEnd, + VirtualProblemKind.NoReferences => Strings.MeterEditor_Problem_NoReferences, + VirtualProblemKind.UnknownMeter => Loc.F(Strings.MeterEditor_Problem_UnknownMeter, Token(problem)), + VirtualProblemKind.SelfReference => Strings.MeterEditor_Problem_SelfReference, + VirtualProblemKind.DependencyCycle => Loc.F(Strings.MeterEditor_Problem_DependencyCycle, Path(problem.MeterIds, name)), + VirtualProblemKind.SourceInvalid => Loc.F(Strings.MeterEditor_Problem_SourceInvalid, Meter(0)), + VirtualProblemKind.SourceNotConfigured => Loc.F(Strings.MeterEditor_Problem_SourceNotConfigured, Meter(0)), + VirtualProblemKind.UnitMismatch => Loc.F(Strings.MeterEditor_Problem_UnitMismatch, Meter(0), Value(0), Meter(1), Value(1)), + VirtualProblemKind.KindMismatch => Loc.F(Strings.MeterEditor_Problem_KindMismatch, Meter(0), KindWord(Value(0)), Meter(1), KindWord(Value(1))), + VirtualProblemKind.ProductNeedsIndicator => Loc.F(Strings.MeterEditor_Problem_ProductNeedsIndicator, Names()), + VirtualProblemKind.IndicatorNeedsUnit => Strings.MeterEditor_Problem_IndicatorNeedsUnit, + VirtualProblemKind.ResultKindRequired => Loc.F( + Strings.MeterEditor_Problem_ResultKindRequired, string.Join(", ", problem.Values.Select(KindWord))), + VirtualProblemKind.ResultKindUnsupported => Strings.MeterEditor_Problem_ResultKindUnsupported, + VirtualProblemKind.ResultKindMismatch => Loc.F(Strings.MeterEditor_Problem_ResultKindMismatch, KindWord(Value(0)), KindWord(Value(1))), + VirtualProblemKind.ResultUnitMismatch => Loc.F(Strings.MeterEditor_Problem_ResultUnitMismatch, Value(0), Value(1)), + VirtualProblemKind.CostRuleNeedsPureSum when problem.MeterIds.Count > 0 => + Loc.F(Strings.MeterEditor_Problem_CostRuleNestedNotSum, Meter(0)), + VirtualProblemKind.CostRuleNeedsPureSum => Strings.MeterEditor_Problem_CostRuleNeedsPureSum, + VirtualProblemKind.CostRuleNeedsLinear => Strings.MeterEditor_Problem_CostRuleNeedsLinear, + VirtualProblemKind.CostRuleNotForIndicator => Strings.MeterEditor_Problem_CostRuleNotForIndicator, + VirtualProblemKind.IndicatorSourceNeedsIndicator => Loc.F(Strings.MeterEditor_Problem_IndicatorSource, Names()), + _ => problem.Kind.ToString(), + }; + } + + /// A syntax error with its 1-based position, so "position 7" matches what a user counts. + public static string FormulaError(FormulaError error) + { + ArgumentNullException.ThrowIfNull(error); + + var position = (error.Position + 1).ToString(CultureInfo.CurrentCulture); + var token = error.Token ?? string.Empty; + return error.Kind switch + { + FormulaErrorKind.Empty => Strings.MeterEditor_FormulaError_Empty, + FormulaErrorKind.TooLong => Loc.F(Strings.MeterEditor_FormulaError_TooLong, FormulaParser.MaxLength), + FormulaErrorKind.TooDeep => Loc.F(Strings.MeterEditor_FormulaError_TooDeep, FormulaParser.MaxDepth), + FormulaErrorKind.UnexpectedCharacter => Loc.F(Strings.MeterEditor_FormulaError_UnexpectedCharacter, token, position), + FormulaErrorKind.UnexpectedToken => Loc.F(Strings.MeterEditor_FormulaError_UnexpectedToken, token, position), + FormulaErrorKind.UnexpectedEnd => Strings.MeterEditor_FormulaError_UnexpectedEnd, + FormulaErrorKind.MissingClosingParenthesis => Loc.F(Strings.MeterEditor_FormulaError_MissingClosingParenthesis, position), + FormulaErrorKind.InvalidNumber => Loc.F(Strings.MeterEditor_FormulaError_InvalidNumber, token), + FormulaErrorKind.UnknownIdentifier => Loc.F(Strings.MeterEditor_FormulaError_UnknownIdentifier, token), + FormulaErrorKind.MeterIdOutOfRange => Loc.F(Strings.MeterEditor_FormulaError_MeterIdOutOfRange, token), + _ => error.ToString(), + }; + } + + /// Why an "always count" (or a change beside one) is refused (D-23), naming the meter it collides with. + public static string Conflict(TotalsConflict conflict, Func name) + { + ArgumentNullException.ThrowIfNull(conflict); + ArgumentNullException.ThrowIfNull(name); + + var other = conflict.OtherMeterId is { } id ? name(id) : Strings.MeterEditor_AnotherMeter; + return conflict.Reason switch + { + TotalsConflictReason.OverlapsCountedMeter => Loc.F(Strings.MeterEditor_Totals_OverlapsCountedMeter, other), + TotalsConflictReason.SourcesOverlap => Loc.F(Strings.MeterEditor_Totals_SourcesOverlap, other), + TotalsConflictReason.NotAPureSum => Strings.MeterEditor_Totals_NotAPureSum, + TotalsConflictReason.NotAdditive => Strings.MeterEditor_Totals_NotAdditive, + TotalsConflictReason.RoleConflict => Loc.F(Strings.MeterEditor_Totals_RoleConflict, other), + TotalsConflictReason.DisplacesOverride => Loc.F(Strings.MeterEditor_Totals_DisplacesOverride, other), + TotalsConflictReason.SourceInOtherEnergyType => Loc.F(Strings.MeterEditor_Totals_SourceInOtherEnergyType, other), + TotalsConflictReason.SourceInOtherMeasure => Loc.F(Strings.MeterEditor_Totals_SourceInOtherMeasure, other), + _ => conflict.Reason.ToString(), + }; + } + + /// Why a legacy meter's links imply no calculation (D-28), for the "needs configuration" notice. + public static string Legacy(LegacyDerivation derivation, Func name) + { + ArgumentNullException.ThrowIfNull(derivation); + ArgumentNullException.ThrowIfNull(name); + + var names = string.Join(", ", derivation.MeterIds.Select(name)); + var values = string.Join(", ", derivation.Values); + return derivation.Outcome switch + { + LegacyDerivationOutcome.NoSources => Strings.MeterEditor_Legacy_NoSources, + LegacyDerivationOutcome.UnknownSource => Strings.MeterEditor_Legacy_UnknownSource, + LegacyDerivationOutcome.SourceNeedsConfiguration => Loc.F(Strings.MeterEditor_Legacy_SourceNeedsConfiguration, names), + LegacyDerivationOutcome.NotAdditive => Loc.F(Strings.MeterEditor_Legacy_NotAdditive, names), + LegacyDerivationOutcome.MixedUnits => Loc.F(Strings.MeterEditor_Legacy_MixedUnits, values), + LegacyDerivationOutcome.MixedKinds => Loc.F( + Strings.MeterEditor_Legacy_MixedKinds, string.Join(", ", derivation.Values.Select(KindWord))), + LegacyDerivationOutcome.UnsupportedKind => Loc.F( + Strings.MeterEditor_Legacy_UnsupportedKind, string.Join(", ", derivation.Values.Select(KindWord))), + LegacyDerivationOutcome.Cycle => Loc.F(Strings.MeterEditor_Legacy_Cycle, Path(derivation.MeterIds, name)), + _ => Strings.MeterEditor_Legacy_Invalid, + }; + } + + /// + /// A kind token of a finding ("generation", "export", "mixed") in the reader's words: the kind's display name, or the + /// token itself when it names no kind. + /// + public static string KindWord(string token) + { + foreach (var kind in Enum.GetValues()) + { + if (string.Equals(VirtualDefinitionJson.KindToken(kind), token, StringComparison.OrdinalIgnoreCase)) + { + return kind.Display(); + } + } + + return string.Equals(token, "mixed", StringComparison.OrdinalIgnoreCase) ? Strings.MeterEditor_MixedKinds : token; + } + + /// A dependency path by name: "Summe → Solar → Summe". + public static string Path(IEnumerable meterIds, Func name) + { + ArgumentNullException.ThrowIfNull(meterIds); + ArgumentNullException.ThrowIfNull(name); + + return string.Join(" → ", meterIds.Select(name)); + } + + private static string Token(VirtualProblem problem) => + problem.MeterIds.Count > 0 ? CalculationDraft.Token(problem.MeterIds[0]) : string.Empty; +} diff --git a/src/App/MeterEditing/VirtualCalculationModel.cs b/src/App/MeterEditing/VirtualCalculationModel.cs new file mode 100644 index 0000000..ebe02c3 --- /dev/null +++ b/src/App/MeterEditing/VirtualCalculationModel.cs @@ -0,0 +1,210 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.App.MeterEditing; + +/// +/// The calculation section of the meter editor as state and rules (D-25, D-26, D-28, D-31, D-39, A-08, A-15): the +/// draft, the meters it may use, its validation against the stored meters, the kind, unit and cost rule it would get by +/// itself, the cost rules it may choose, and what the stored meter's links say. The section component only renders it; +/// the editor asks it whether a save may go ahead. +/// +/// +/// Validation runs three times per change — with nothing declared (what the sources imply), with the declared kind and +/// unit (which cost rules fit), and as typed (what a save would store) — each a pure pass over the formula, so the +/// verdict shown is always the one a save would reach. A chosen cost rule the formula no longer supports is dropped back +/// to the default rather than kept as an error the user did not make. +/// +public sealed class VirtualCalculationModel +{ + private readonly Dictionary _names; + + /// Sets up the section for the meter (). + /// The stored meters, as analysis reads them. + /// The meter being edited; for a new one. + /// Its energy type (its meters are offered first). + public VirtualCalculationModel(AnalysisCatalog catalog, int draftId, int energyTypeId) + { + ArgumentNullException.ThrowIfNull(catalog); + + Catalog = catalog; + DraftId = draftId; + _names = catalog.Meters.Values.ToDictionary(m => m.Id, m => m.Name); + Options = CalculationSources.For(catalog, draftId, energyTypeId); + Stored = catalog.Find(draftId); + CurrentLinks = [.. catalog.Links.Where(l => l.ToMeterId == draftId).Select(l => l.FromMeterId).Order()]; + + // A stored calculation opens as it is. A legacy meter opens with the sum its links imply, as a proposal a save + // confirms (D-28); one whose links imply nothing opens empty and says why. + var stored = Stored is { IsVirtual: true } ? Stored : null; + Status = stored?.VirtualStatus; + Legacy = stored?.Legacy; + var start = stored?.VirtualStatus switch + { + VirtualMeterStatus.Valid or VirtualMeterStatus.Invalid or VirtualMeterStatus.Malformed => stored.StoredDefinition?.Definition ?? stored.Definition, + VirtualMeterStatus.Legacy => stored.Definition, + _ => null, + }; + Draft = CalculationDraft.From(start); + Simplify(); + + // A meter without links gets its sum's sources linked, and links that matched the stored sum stay in step with it, + // by default. Links someone drew differently (a physical meter turned virtual, a legacy meter whose links imply + // nothing) are only changed when asked to: topology is never rewritten behind the user's back. + SyncLinks = CurrentLinks.Count == 0 + || (Draft.Mode == CalculationMode.Sum && Draft.SumSources.Order().SequenceEqual(CurrentLinks)); + Refresh(); + } + + /// The stored meters. + public AnalysisCatalog Catalog { get; } + + /// The meter being edited, as the catalog knows it. + public int DraftId { get; } + + /// The stored meter, or null for a new one. + public AnalysisMeter? Stored { get; } + + /// How the stored meter's definition stands; null for a new or a physical meter. + public VirtualMeterStatus? Status { get; } + + /// The legacy derivation of a stored meter without an expression. + public LegacyDerivation? Legacy { get; } + + /// The calculation being edited. + public CalculationDraft Draft { get; } + + /// Every meter the calculation may refer to. + public IReadOnlyList Options { get; } + + /// The meters linked into the stored meter now (its incoming topology links). + public IReadOnlyList CurrentLinks { get; } + + /// + /// For a sum: also make the flow links into this meter match its sources on save (brief §5.1). Never the other way + /// round: links never change a saved calculation. + /// + public bool SyncLinks { get; set; } + + /// The draft as typed, validated; null while it is incomplete. + public VirtualValidation? Validation { get; private set; } + + /// The kind the sources imply by themselves; null when they do not imply one. + public QuantityKind? InferredKind { get; private set; } + + /// The unit the sources imply by themselves; null for a product or ratio, or while unknown. + public string? InferredUnit { get; private set; } + + /// The cost rule a formula of this shape and kind gets when none is chosen. + public VirtualCostRule DefaultCostRule { get; private set; } + + /// The cost rules this formula may choose. + public IReadOnlyList OfferedCostRules { get; private set; } = [VirtualCostRule.None]; + + /// True when the last refresh dropped a chosen cost rule the formula no longer supports. + public bool CostRuleReset { get; private set; } + + /// The effective result kind (declared or inferred); null while undecided. + public QuantityKind? EffectiveKind => Validation?.Kind ?? Draft.ResultKind ?? InferredKind; + + /// A legacy meter whose implied sum is offered for confirmation. + public bool IsLegacyProposal => Status == VirtualMeterStatus.Legacy; + + /// True when the draft may be saved: complete, valid, and with a cost rule the whole calculation supports. + public bool CanSave => !Draft.IsIncomplete && Validation is { IsSavable: true }; + + /// What a save stores (A-08); null unless . + public VirtualDefinition? EffectiveDefinition => CanSave ? Validation!.EffectiveDefinition : null; + + /// The problems to show: validation findings, and a cost rule the calculation does not support. + public IReadOnlyList Problems => + Validation is null ? [] : [.. Validation.Problems, .. Validation.CostRuleProblem is { } cost ? [cost] : Array.Empty()]; + + /// For a sum whose sources differ from the stored links: the links a sync would add. + public IReadOnlyList LinksToAdd => IsSum ? [.. Draft.SumSources.Where(id => !CurrentLinks.Contains(id))] : []; + + /// For a sum whose sources differ from the stored links: the links a sync would remove. + public IReadOnlyList LinksToRemove => IsSum ? [.. CurrentLinks.Where(id => !Draft.SumSources.Contains(id))] : []; + + /// True when saving could sync links: a complete sum whose sources are not exactly the linked meters. + public bool OffersLinkSync => IsSum && !Draft.IsIncomplete && (LinksToAdd.Count > 0 || LinksToRemove.Count > 0); + + /// The incoming links a save leaves: the sum's sources when syncing, otherwise the stored ones. + public IReadOnlyCollection LinksAfterSave => OffersLinkSync && SyncLinks ? Draft.SumSources : CurrentLinks; + + private bool IsSum => Draft.Mode == CalculationMode.Sum; + + /// A meter's name, or its formula token when no meter has that id. + public string Name(int meterId) => _names.TryGetValue(meterId, out var name) ? name : CalculationDraft.Token(meterId); + + /// A source option by id, or null. + public SourceOption? Option(int meterId) => Options.FirstOrDefault(o => o.MeterId == meterId); + + /// Validates the draft again after any change and updates everything derived from it. + public void Refresh() + { + CostRuleReset = false; + if (Draft.IsIncomplete) + { + Validation = null; + InferredKind = null; + InferredUnit = null; + OfferedCostRules = [VirtualCostRule.None]; + DefaultCostRule = VirtualCostRule.None; + return; + } + + var catalog = Catalog.Catalog; + var typed = Draft.Definition; + var implied = VirtualValidator.Validate(typed with { ResultKind = null, ResultUnit = null, CostRule = null }, DraftId, catalog); + InferredKind = implied.Problems.Any(p => p.Kind == VirtualProblemKind.ResultKindRequired) ? null : implied.Kind; + InferredUnit = implied.Unit; + + var declared = VirtualValidator.Validate(typed with { CostRule = null }, DraftId, catalog); + DefaultCostRule = declared.CostRule; + OfferedCostRules = CalculationSources.OfferedCostRules(declared.Formula, declared.Kind, DraftId, catalog); + if (Draft.CostRule is { } chosen && !OfferedCostRules.Contains(chosen)) + { + Draft.CostRule = null; + CostRuleReset = true; + } + + Validation = VirtualValidator.Validate(Draft.Definition, DraftId, catalog); + } + + /// + /// A stored definition declares everything (A-08). What merely repeats what the sources imply is shown as automatic + /// instead, so changing the sources carries the kind, unit and cost rule along rather than contradicting them; a + /// save writes the effective values again. + /// + private void Simplify() + { + if (Draft.IsIncomplete) + { + return; + } + + var catalog = Catalog.Catalog; + var implied = VirtualValidator.Validate(Draft.Definition with { ResultKind = null, ResultUnit = null, CostRule = null }, DraftId, catalog); + if (implied.Problems.All(p => p.Kind != VirtualProblemKind.ResultKindRequired) && implied.Kind == Draft.ResultKind) + { + Draft.ResultKind = null; + } + + if (implied.Unit is not null && MeterVault.Core.Analysis.Quantities.Units.AreSame(implied.Unit, Draft.ResultUnit)) + { + Draft.ResultUnit = null; + } + + var declared = VirtualValidator.Validate(Draft.Definition with { CostRule = null }, DraftId, catalog); + if (Draft.CostRule == declared.CostRule) + { + Draft.CostRule = null; + } + } + + /// Why a cost rule is not offered here, or null when it is. + public CostRuleUnavailable? WhyNot(VirtualCostRule rule) => + Draft.IsIncomplete ? null : CalculationSources.WhyNot(rule, Validation?.Formula, Validation?.Kind ?? Draft.ResultKind, DraftId, Catalog.Catalog); +} diff --git a/src/App/MeterEditing/VirtualPreviewPeriod.cs b/src/App/MeterEditing/VirtualPreviewPeriod.cs new file mode 100644 index 0000000..e9d034a --- /dev/null +++ b/src/App/MeterEditing/VirtualPreviewPeriod.cs @@ -0,0 +1,54 @@ +using MeterVault.App.Analysis; +using MeterVault.Core.Analysis; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.App.MeterEditing; + +/// +/// The period the meter editor's calculation preview shows (brief §5.1, D-31: "a live preview for the selected period"): +/// it opens on the period of the page the editor was opened from — a historical month or year stays that month or +/// year — and offers the relative presets, all available history (the sources' own dates, however old) and custom +/// dates, through the shared period toolbar. Only the period is read from the page; the preview is always monthly. +/// +public static class VirtualPreviewPeriod +{ + /// The defaults of the preview: the history pages' (last 12 months). + public static AnalysisDefaults Defaults => AnalysisDefaults.History; + + /// + /// The preview's first period: the page's () when the editor was opened from one — its preset, + /// or its dates; otherwise the last 12 months. + /// + public static AnalysisQuery Initial(AnalysisQuery? page) + { + var query = AnalysisQuery.Default(Defaults); + if (page is null) + { + return query; + } + + if (page.IsCustom && page.From is { } first && page.To is { } last && PeriodResolver.IsValidCustomRange(first, last)) + { + return query.WithCustomRange(first, last); + } + + return page.IsCustom ? query : query.WithPeriod(page.Period); + } + + /// + /// Resolves for the preview of as of : all + /// available history spans the dates its sources have data for (). + /// + public static async Task ResolveAsync( + MeterDraftAnalysis analysis, AnalysisCatalog overlay, MeterDraft draft, AnalysisQuery query, DateTimeOffset now, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(analysis); + ArgumentNullException.ThrowIfNull(query); + + var availability = query.Period == PeriodPreset.AllHistory + ? await analysis.SourceAvailabilityAsync(overlay, draft, now, cancellationToken).ConfigureAwait(false) + : null; + return query.Resolve(now, analysis.Zone, availability); + } +} diff --git a/src/App/MeterLinks.cs b/src/App/MeterLinks.cs index 034ebf6..5d19fec 100644 --- a/src/App/MeterLinks.cs +++ b/src/App/MeterLinks.cs @@ -1,4 +1,5 @@ using System.Globalization; +using MeterVault.App.Analysis; using MeterVault.Core.Domain; namespace MeterVault.App; @@ -9,17 +10,41 @@ namespace MeterVault.App; /// rather than to the top of the page and a hunt for the right button. ///
/// +/// /// /meters/{id}?tab=events&action=swap opens the Events tab with the swap dialog up. The /// action is consumed once and dropped from the address, so reloading the page does not reopen it. +/// +/// +/// Tabs are addressed by stable keys (D-47): analysis|readings|normalized|events|tariffs|sources|calculation. +/// Which of them a meter shows depends on its mode (), and maps any +/// requested key — including the old consumption, and sources/readings on a virtual meter — onto +/// one it shows, so old links keep working. Analysis links carry the period keys of an +/// after the existing tab/action keys. +/// /// public static class MeterLinks { + /// The default tab: period figures, chart, comparison, table and export (brief §7.2). + public const string TabAnalysis = "analysis"; + public const string TabReadings = "readings"; + + /// + /// The legacy key of the normalized-data tab, kept valid forever for old links; it resolves to + /// . + /// public const string TabConsumption = "consumption"; + + /// The normalized (derived) data tab — what the old Consumption tab showed. + public const string TabNormalized = "normalized"; + public const string TabEvents = "events"; public const string TabTariffs = "tariffs"; public const string TabSources = "sources"; + /// A virtual meter's formula and sources (D-31); it replaces Sources on a virtual meter. + public const string TabCalculation = "calculation"; + public const string ActionReading = "reading"; public const string ActionEdit = "edit"; public const string ActionSource = "source"; @@ -29,8 +54,12 @@ public static class MeterLinks public const string ParamSourceType = "type"; public const string ParamConnector = "connector"; - /// Tab keys in the order the meter page renders its panels. - public static readonly IReadOnlyList Tabs = [TabReadings, TabConsumption, TabEvents, TabTariffs, TabSources]; + private static readonly IReadOnlyList PhysicalTabs = + [TabAnalysis, TabReadings, TabNormalized, TabEvents, TabTariffs, TabSources]; + + // A virtual meter has no raw readings and stores no derived rows — it is evaluated on read (D-58, SDD §14.1), so its + // per-bucket values are the Analysis table — and its Calculation replaces Sources (D-31). Events keeps Note. + private static readonly IReadOnlyList VirtualTabs = [TabAnalysis, TabEvents, TabTariffs, TabCalculation]; public static string Detail(int meterId, string? tab = null, string? action = null) { @@ -132,12 +161,53 @@ public static class MeterLinks return null; } - /// The panel index for a tab key; unknown or missing keys open the first tab. - public static int TabIndex(string? tab) + /// + /// The tabs a meter of shows, in order: Analysis, Readings, Normalized data, Events, + /// Tariffs, Sources — and for a virtual meter Analysis, Events, Tariffs, Calculation (D-31). A tank keeps every tab; + /// its levels and deliveries are events, and its Readings tab says so. + /// + public static IReadOnlyList VisibleTabs(MeterMode mode) => mode == MeterMode.Virtual ? VirtualTabs : PhysicalTabs; + + /// + /// The tab a requested key opens on a meter of (D-47): the key itself when the meter shows + /// it; consumption to normalized; on a virtual meter sources to calculation and + /// readings to analysis; anything missing, unknown or not shown for the mode to analysis. + /// Case and surrounding blanks are ignored; the result is always one of . + /// + public static string ResolveTab(string? tab, MeterMode mode) { - for (var i = 0; i < Tabs.Count; i++) + var key = tab?.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(key)) { - if (string.Equals(Tabs[i], tab, StringComparison.OrdinalIgnoreCase)) + return TabAnalysis; + } + + if (key == TabConsumption) + { + key = TabNormalized; + } + + if (mode == MeterMode.Virtual) + { + key = key switch + { + TabSources => TabCalculation, + TabReadings => TabAnalysis, + _ => key, + }; + } + + return VisibleTabs(mode).Contains(key, StringComparer.Ordinal) ? key : TabAnalysis; + } + + /// The panel index of a requested tab among for . + public static int PanelIndex(string? tab, MeterMode mode) + { + var tabs = VisibleTabs(mode); + var key = ResolveTab(tab, mode); + for (var i = 0; i < tabs.Count; i++) + { + if (string.Equals(tabs[i], key, StringComparison.Ordinal)) { return i; } @@ -145,4 +215,18 @@ public static class MeterLinks return 0; } + + /// + /// The meter's Analysis tab, carrying the period of (period, bucket, comparison, metric — + /// never its scope, which is the meter) as far as it differs from the history defaults (D-02): + /// /meters/42?tab=analysis&period=ytd. + /// + public static string Analysis(int meterId, AnalysisQuery? query = null) => Detail(meterId, TabAnalysis, action: null, query); + + /// + /// A tab (and optionally a one-shot action) of the meter page, carrying the period of after + /// the existing keys (D-47) — a drill-down into Normalized data keeps the range it came from. + /// + public static string Detail(int meterId, string? tab, string? action, AnalysisQuery? query) => + query is null ? Detail(meterId, tab, action) : query.AppendTo(Detail(meterId, tab, action), AnalysisDefaults.History); } diff --git a/src/App/NavGroups.cs b/src/App/NavGroups.cs new file mode 100644 index 0000000..18f1117 --- /dev/null +++ b/src/App/NavGroups.cs @@ -0,0 +1,98 @@ +namespace MeterVault.App; + +/// +/// The collapsible groups of the navigation menu (D-48) and their cookie form: which are expanded, and which one holds +/// a route — so the group of the page being shown is open after a reload, a bookmark or the language switch. +/// +/// +/// The cookie () lists the expanded groups joined by dots (types.views), +/// or none. Without it — nobody has folded anything yet — the analysis groups start open and Configuration +/// closed. Unknown names are ignored, so renaming a group can never break the menu. +/// +public static class NavGroups +{ + /// The user-defined energy types (/energy/{id}). + public const string EnergyTypes = "types"; + + /// Solar and Tanks & consumables. + public const string SpecializedViews = "views"; + + /// The /admin/* pages. + public const string Configuration = "config"; + + private const string NoneToken = "none"; + + /// Every group, in menu order. + public static IReadOnlyList All { get; } = [EnergyTypes, SpecializedViews, Configuration]; + + /// What is expanded before anybody chose: the analysis groups. + public static IReadOnlyList DefaultExpanded { get; } = [EnergyTypes, SpecializedViews]; + + /// + /// The expanded groups a cookie names; null when there is no usable cookie (then + /// applies). none is a real choice: everything folded. + /// + public static IReadOnlySet? Parse(string? cookie) + { + if (string.IsNullOrWhiteSpace(cookie)) + { + return null; + } + + var text = Uri.UnescapeDataString(cookie.Trim()); + if (string.Equals(text, NoneToken, StringComparison.OrdinalIgnoreCase)) + { + return new HashSet(StringComparer.Ordinal); + } + + var groups = new HashSet(StringComparer.Ordinal); + foreach (var part in text.Split('.', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (All.FirstOrDefault(g => string.Equals(g, part, StringComparison.OrdinalIgnoreCase)) is { } group) + { + groups.Add(group); + } + } + + // A cookie that names nothing we know is no choice at all. + return groups.Count == 0 ? null : groups; + } + + /// The cookie value for a set of expanded groups, in menu order. + public static string Format(IEnumerable expanded) + { + ArgumentNullException.ThrowIfNull(expanded); + + var set = expanded.ToHashSet(StringComparer.OrdinalIgnoreCase); + var names = All.Where(set.Contains).ToList(); + return names.Count == 0 ? NoneToken : string.Join('.', names); + } + + /// + /// The group holding a route, from its base-relative or absolute path (query ignored): energy type pages, the + /// specialized views, the configuration pages; null for a top-level entry (Overview, Analysis, Meters, Data import). + /// + public static string? GroupFor(string? path) + { + var text = path ?? string.Empty; + var cut = text.IndexOfAny(['?', '#']); + if (cut >= 0) + { + text = text[..cut]; + } + + if (Uri.TryCreate(text, UriKind.Absolute, out var absolute) && absolute.Scheme is "http" or "https") + { + text = absolute.AbsolutePath; + } + + var segment = text.Trim('/').Split('/')[0]; + return segment.ToLowerInvariant() switch + { + "energy" => EnergyTypes, + "solar" or "consumables" => SpecializedViews, + "admin" => Configuration, + _ => null, + }; + } +} diff --git a/src/App/NavState.cs b/src/App/NavState.cs index 273e264..cb1b950 100644 --- a/src/App/NavState.cs +++ b/src/App/NavState.cs @@ -5,9 +5,27 @@ namespace MeterVault.App; /// layout, which Blazor keeps for the whole circuit, so without this a new or renamed energy type /// would not appear in it until the browser reloaded. ///
+/// +/// It also carries the expanded navigation groups the request arrived with (): App.razor +/// reads the cookie, Routes hands it here through , and the menu starts from it. +/// public sealed class NavState { public event Action? EnergyTypesChanged; + /// + /// Raised after a meter was created, edited or deleted: the menu re-reads what the specialized views need (a + /// generation counter for Solar, a tank for Tanks & consumables). + /// + public event Action? MetersChanged; + + /// The expanded groups the request's cookie named; null when it named none (the defaults apply). + public IReadOnlySet? SavedGroups { get; private set; } + public void NotifyEnergyTypesChanged() => EnergyTypesChanged?.Invoke(); + + public void NotifyMetersChanged() => MetersChanged?.Invoke(); + + /// Takes the expanded groups from the navigation cookie of the request that opened this circuit. + public void InitializeGroups(string? cookie) => SavedGroups = NavGroups.Parse(cookie); } diff --git a/src/App/Program.cs b/src/App/Program.cs index 864a7d1..1e9939a 100644 --- a/src/App/Program.cs +++ b/src/App/Program.cs @@ -1,3 +1,4 @@ +using MeterVault.App.Analysis; using MeterVault.App.Api; using MeterVault.App.Components; using MeterVault.App.Localization; @@ -76,8 +77,17 @@ try builder.Services.AddLocalization(); builder.Services.AddMudServices(); + // MudBlazor's own accessible names and messages in the app's languages (brief §8); it ships English only. + builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + // UI foundation of the analysis pages: the circuit's light/dark mode (D-49), the instance currency (D-43), period + // resolution with availability (D-19) and the CSV export (D-55). + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); @@ -133,6 +143,7 @@ try app.MapMeterVaultApi(); app.MapCultureEndpoints(); + app.MapAnalysisExport(); // Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9). app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); @@ -187,6 +198,12 @@ static async Task MigrateDatabaseAsync(WebApplication app) Log.Information("Reference dataset ensured (SeedReferenceData=true)"); } + // Expression-less virtual meters from before the analysis rework get the sum their links imply stored as an + // explicit definition (D-28); the rest are logged as needing configuration. Idempotent, and never fatal. + await scope.ServiceProvider + .GetRequiredService() + .RunAsync().ConfigureAwait(false); + // After the data is in place: stored consumption is derived, so when the engine starts booking // readings differently, existing meters are rebuilt once rather than only as new readings arrive. var rebuilt = await scope.ServiceProvider diff --git a/src/App/TariffEditing/TariffDeepLink.cs b/src/App/TariffEditing/TariffDeepLink.cs new file mode 100644 index 0000000..7db3d90 --- /dev/null +++ b/src/App/TariffEditing/TariffDeepLink.cs @@ -0,0 +1,82 @@ +using System.Globalization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Domain; + +namespace MeterVault.App.TariffEditing; + +/// +/// What a link into the tariff editor asks for (D-52): /admin/tariffs?scope=&id=&component=&from=&action=new +/// (). The scope (and its energy type or meter id) scopes the list; with action=new a +/// new-tariff dialog opens once, prefilled with the scope, the component and the first month the price is missing +/// from. +/// +/// +/// Invalid tokens are ignored one by one rather than failing the page: an unknown scope shows every tariff, an unknown +/// component prefills a unit price, an unreadable date prefills today. A type or meter scope without a usable id is no +/// scope at all, because there is nothing to scope to; global needs none, and any id beside it is ignored. +/// +/// The scope to list and prefill; null for none (every tariff). +/// The energy type or meter of a type or meter scope; null for global. +/// The component to prefill; null when not given or unknown. +/// The first day to prefill (the first uncovered month's 1st); null when not given or unreadable. +/// True when the link asks for the new-tariff dialog (action=new). +public sealed record TariffDeepLink(TariffScope? Scope, int? ScopeId, TariffComponent? Component, DateOnly? From, bool OpenNew) +{ + /// A link with nothing in it: the plain tariff list. + public static TariffDeepLink None { get; } = new(null, null, null, null, false); + + /// True when the list is scoped. + public bool HasScope => Scope is not null; + + /// Reads the link's query values (as [SupplyParameterFromQuery] delivers them). + public static TariffDeepLink Parse(string? scope, string? id, string? component, string? from, string? action) + { + TariffScope? parsedScope = TariffLinks.TryParseScope(scope, out var s) ? s : null; + int? scopeId = int.TryParse(id, NumberStyles.None, CultureInfo.InvariantCulture, out var i) && i > 0 ? i : null; + if (parsedScope is null or TariffScope.Global) + { + // An id means nothing without a scope that takes one. + scopeId = null; + } + else if (scopeId is null) + { + parsedScope = null; + } + + TariffComponent? parsedComponent = TariffLinks.TryParseComponent(component, out var c) ? c : null; + DateOnly? parsedFrom = AnalysisTokens.TryParseDate(from, out var d) ? d : null; + var openNew = string.Equals(action?.Trim(), TariffLinks.ActionNew, StringComparison.OrdinalIgnoreCase); + return new TariffDeepLink(parsedScope, scopeId, parsedComponent, parsedFrom, openNew); + } + + /// + /// True when belongs in the scoped list: the tariffs that can price the scope, in the + /// resolver's precedence (meter, energy type, global). A meter shows its own tariffs, its energy type's and the + /// global ones; an energy type its own, its meters' and the global ones; global the global ones. Without a scope + /// every tariff is listed. + /// + /// The tariff. + /// The energy type of a meter id (for a meter scope, and for meter tariffs under a type scope). + public bool Lists(Tariff tariff, Func meterEnergyType) + { + ArgumentNullException.ThrowIfNull(tariff); + ArgumentNullException.ThrowIfNull(meterEnergyType); + + switch (Scope) + { + case null: + return true; + case TariffScope.Global: + return tariff.ScopeType == TariffScope.Global; + case TariffScope.EnergyType: + return tariff.ScopeType == TariffScope.Global + || (tariff.ScopeType == TariffScope.EnergyType && tariff.ScopeId == ScopeId) + || (tariff.ScopeType == TariffScope.Meter && tariff.ScopeId is { } meter && meterEnergyType(meter) == ScopeId); + default: + var type = ScopeId is { } id ? meterEnergyType(id) : null; + return tariff.ScopeType == TariffScope.Global + || (tariff.ScopeType == TariffScope.Meter && tariff.ScopeId == ScopeId) + || (tariff.ScopeType == TariffScope.EnergyType && type is not null && tariff.ScopeId == type); + } + } +} diff --git a/src/App/TariffEditing/TariffUnitCheck.cs b/src/App/TariffEditing/TariffUnitCheck.cs new file mode 100644 index 0000000..cc3246f --- /dev/null +++ b/src/App/TariffEditing/TariffUnitCheck.cs @@ -0,0 +1,274 @@ +using MeterVault.App.Localization; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.App.TariffEditing; + +/// The meters a tariff would price that share one normalized unit (D-20), by name. +/// The meters' names (user data), or an energy type's name when it has no billed meter yet. +/// Their normalized unit, canonical. +public sealed record TariffUnitTarget(string Names, string Unit); + +/// The overall verdict on a tariff's unit. +public enum TariffUnitVerdictKind +{ + /// Understood, and fits everything the tariff would price. + Fits, + + /// Applies, with a warning: the unit (or a meter's) cannot be read, or it fits only some of what the scope prices. + Warning, + + /// Understood and wrong: another shape, another currency, a period that cannot be accrued, or it fits nothing. + Blocked, + + /// Bonus, Discount and Tax are stored but not applied yet (D-37, D-57). + NotApplied, +} + +/// What exactly is wrong (or noteworthy) about a tariff's unit. +public enum TariffUnitProblem +{ + None, + + /// The unit cannot be read; it applies at face value (or per month) with a warning. + Unreadable, + + /// A unit or feed-in price per period, or a base price per quantity. + WrongShape, + + /// A standing charge per a period that cannot be spread over days ("EUR/2 Monate"). + UnsupportedPeriod, + + /// Quoted in another currency than the instance bills in (D-43). + Currency, + + /// Its denominator converts into none of the units the scope prices. + NoFit, + + /// It fits some of the units the scope prices and not others (a global price over kWh and m³). + PartlyFits, + + /// A meter's unit is one the checker does not know; that part applies with a warning. + Unverified, + + /// Nothing in the scope is billed yet, so there is nothing to check the denominator against. + NoTargets, +} + +/// One unit group the tariff was checked against, and the verdict for it. +public sealed record TariffUnitLine(TariffUnitTarget Target, TariffApplicability Applicability); + +/// The tariff editor's verdict on a unit (D-37): how it was read, what it was checked against, and whether it may be saved. +public sealed record TariffUnitVerdict( + ParsedTariffUnit Parsed, + TariffComponent Component, + TariffUnitVerdictKind Kind, + TariffUnitProblem Problem, + IReadOnlyList Lines) +{ + /// True when the unit must be fixed before the tariff is saved. + public bool Blocks => Kind == TariffUnitVerdictKind.Blocked; +} + +/// +/// Checks a tariff's unit on save (D-37) against its scope's normalized units: a unit or feed-in price must be a currency +/// per a quantity that converts into what the scope bills (known scales — ct, per 100 L, per MWh — are converted), a base +/// price a currency per day, month, quarter or year, and either in the instance currency. A unit that cannot be read is +/// allowed with a warning, as the cost engine applies it at face value; a unit that is read and wrong is refused, because +/// the cost engine would leave it out ("unavailable (unit)"). +/// +public static class TariffUnitCheck +{ + /// Checks for against . + public static TariffUnitVerdict Check(string? unit, TariffComponent component, IReadOnlyList targets, string currency) + { + ArgumentNullException.ThrowIfNull(targets); + ArgumentException.ThrowIfNullOrWhiteSpace(currency); + + var parsed = TariffUnit.Parse(unit); + if (component is not (TariffComponent.UnitPrice or TariffComponent.FeedIn or TariffComponent.BasePrice)) + { + return Verdict(TariffUnitVerdictKind.NotApplied, TariffUnitProblem.None); + } + + if (parsed.Basis == TariffUnitBasis.Unparseable) + { + return Verdict(TariffUnitVerdictKind.Warning, TariffUnitProblem.Unreadable); + } + + if (component == TariffComponent.BasePrice) + { + if (parsed.Basis == TariffUnitBasis.UnsupportedPeriod) + { + return Verdict(TariffUnitVerdictKind.Blocked, TariffUnitProblem.UnsupportedPeriod); + } + + if (!parsed.Suits(component)) + { + return Verdict(TariffUnitVerdictKind.Blocked, TariffUnitProblem.WrongShape); + } + + return TariffUnit.BaseAccrual(parsed, currency).Issue == TariffUnitIssue.CurrencyMismatch + ? Verdict(TariffUnitVerdictKind.Blocked, TariffUnitProblem.Currency) + : Verdict(TariffUnitVerdictKind.Fits, TariffUnitProblem.None); + } + + if (!parsed.Suits(component)) + { + return Verdict(TariffUnitVerdictKind.Blocked, TariffUnitProblem.WrongShape); + } + + // The currency alone: checked against the unit's own denominator, which always converts. + if (TariffUnit.Applicability(parsed, parsed.Denominator, component, currency).Issue == TariffUnitIssue.CurrencyMismatch) + { + return Verdict(TariffUnitVerdictKind.Blocked, TariffUnitProblem.Currency); + } + + if (targets.Count == 0) + { + return Verdict(TariffUnitVerdictKind.Fits, TariffUnitProblem.NoTargets); + } + + List lines = [.. targets.Select(t => new TariffUnitLine(t, TariffUnit.Applicability(parsed, t.Unit, component, currency)))]; + var mismatches = lines.Count(l => l.Applicability.Fit == TariffUnitFit.Mismatch); + if (mismatches == lines.Count) + { + return Verdict(TariffUnitVerdictKind.Blocked, TariffUnitProblem.NoFit, lines); + } + + if (mismatches > 0) + { + return Verdict(TariffUnitVerdictKind.Warning, TariffUnitProblem.PartlyFits, lines); + } + + return lines.Exists(l => l.Applicability.NeedsWarning) + ? Verdict(TariffUnitVerdictKind.Warning, TariffUnitProblem.Unverified, lines) + : Verdict(TariffUnitVerdictKind.Fits, TariffUnitProblem.None, lines); + + TariffUnitVerdict Verdict(TariffUnitVerdictKind kind, TariffUnitProblem problem, IReadOnlyList? checkedLines = null) => + new(parsed, component, kind, problem, checkedLines ?? []); + } + + /// + /// What a tariff of and would price, grouped by normalized unit: + /// the meter itself for a meter scope; for an energy type its billed meters (feed-in: its grid-export meters), or the + /// type's base unit while it bills nothing yet; for global every billed (or credited) meter of every type. A base + /// price is quoted per period and has no quantity to check, so it has none. + /// + /// The meters as analysis reads them (normalized units, billing, D-34). + /// The tariff's scope. + /// The energy type or meter id. + /// The tariff's component. + /// An energy type's name and base unit by id, or null when it does not exist. + public static IReadOnlyList TargetsFor( + AnalysisCatalog catalog, TariffScope scope, int? scopeId, TariffComponent component, Func energyType) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(energyType); + + if (component is not (TariffComponent.UnitPrice or TariffComponent.FeedIn)) + { + return []; + } + + IEnumerable Billed(TypeBilling billing) => component == TariffComponent.FeedIn + ? billing.FeedInMeterIds + : billing.Lines.Where(l => l.Kind == BillLineKind.UnitPrice).Select(l => l.MeterId); + + switch (scope) + { + case TariffScope.Meter: + return scopeId is { } meterId && catalog.Find(meterId) is { } meter ? Group([meter]) : []; + + case TariffScope.EnergyType when scopeId is { } typeId: + var billed = Billed(catalog.Totals.ForType(typeId).Billing).Select(catalog.Find).OfType().ToList(); + if (billed.Count > 0) + { + return Group(billed); + } + + return component == TariffComponent.UnitPrice && energyType(typeId) is { } type && !string.IsNullOrWhiteSpace(type.BaseUnit) + ? [new TariffUnitTarget(type.Name, Units.Normalize(type.BaseUnit))] + : []; + + case TariffScope.Global: + return Group(catalog.Totals.Types.Values + .SelectMany(t => Billed(t.Billing)) + .Distinct() + .Select(catalog.Find) + .OfType()); + + default: + return []; + } + } + + /// The verdict as lines for the dialog, each flagged as an error or not; the first says how the unit was read. + public static IReadOnlyList<(bool IsError, string Text)> Describe(TariffUnitVerdict verdict, string currency) + { + ArgumentNullException.ThrowIfNull(verdict); + + var parsed = verdict.Parsed; + var lines = new List<(bool, string)>(); + if (verdict.Kind == TariffUnitVerdictKind.NotApplied) + { + lines.Add((false, Strings.Tariffs_NotAppliedNote)); + return lines; + } + + switch (verdict.Problem) + { + case TariffUnitProblem.Unreadable: + lines.Add((false, verdict.Component == TariffComponent.BasePrice ? Strings.Tariffs_UnitUnreadableBase : Strings.Tariffs_UnitUnreadable)); + return lines; + case TariffUnitProblem.WrongShape: + lines.Add((true, verdict.Component == TariffComponent.BasePrice ? Strings.Tariffs_UnitWrongShapePeriod : Strings.Tariffs_UnitWrongShapeQuantity)); + return lines; + case TariffUnitProblem.UnsupportedPeriod: + lines.Add((true, Loc.F(Strings.Tariffs_UnitUnsupportedPeriod, parsed.DenominatorText))); + return lines; + case TariffUnitProblem.Currency: + lines.Add((true, Loc.F(Strings.Tariffs_UnitCurrency, parsed.Currency ?? parsed.Raw, currency))); + return lines; + } + + lines.Add((false, parsed.Basis == TariffUnitBasis.Period && parsed.Period is { } period + ? Loc.F(Strings.Tariffs_UnitPerPeriod, parsed.Currency ?? string.Empty, PeriodWord(period)) + : Loc.F(Strings.Tariffs_UnitReadAs, parsed.Currency ?? string.Empty, parsed.DenominatorText))); + + if (verdict.Problem == TariffUnitProblem.NoTargets) + { + lines.Add((false, Strings.Tariffs_UnitNoTargets)); + } + + foreach (var line in verdict.Lines) + { + var target = line.Target; + lines.Add(line.Applicability.Fit switch + { + TariffUnitFit.Mismatch => (true, Loc.F(Strings.Tariffs_UnitMismatch, target.Names, target.Unit)), + TariffUnitFit.Unverified => (false, Loc.F(Strings.Tariffs_UnitUnverifiedMeter, target.Names, target.Unit)), + _ => (false, Loc.F(Strings.Tariffs_UnitFits, target.Names, target.Unit)), + }); + } + + return lines; + } + + /// A billing period in words ("month" / "Monat"). + public static string PeriodWord(BillingPeriod period) => period switch + { + BillingPeriod.Day => Strings.Tariffs_Period_Day, + BillingPeriod.Quarter => Strings.Tariffs_Period_Quarter, + BillingPeriod.Year => Strings.Tariffs_Period_Year, + _ => Strings.Tariffs_Period_Month, + }; + + private static List Group(IEnumerable meters) => + [.. meters + .GroupBy(m => Units.Normalize(m.Quantity.Unit)) + .OrderBy(g => g.Key, StringComparer.Ordinal) + .Select(g => new TariffUnitTarget(string.Join(", ", g.Select(m => m.Name).Order(StringComparer.CurrentCultureIgnoreCase)), g.Key))]; +} diff --git a/src/App/TariffEditing/TariffValidity.cs b/src/App/TariffEditing/TariffValidity.cs new file mode 100644 index 0000000..72c59be --- /dev/null +++ b/src/App/TariffEditing/TariffValidity.cs @@ -0,0 +1,40 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.App.TariffEditing; + +/// One stored tariff's validity, as far as its effective end is concerned. +/// The tariff's id. +/// Global, energy type or meter. +/// The energy type or meter it belongs to; null for global. +/// What it prices. +/// Its first day. +/// Its stored last day, or null when none was entered. +public readonly record struct TariffSpan( + int Id, TariffScope Scope, int? ScopeId, TariffComponent Component, DateOnly ValidFrom, DateOnly? ValidTo); + +/// +/// When a tariff effectively ends, so the tariff list and a meter's Tariffs tab say the same (brief §7.2): its stored end, +/// or the day before the next tariff of the same scope and component starts, whichever comes first — a later price +/// replaces an earlier one without anyone closing it. Null means it is still open. +/// +public static class TariffValidity +{ + /// Each tariff's effective last day by id; null when open-ended. + public static IReadOnlyDictionary EffectiveEnds(IEnumerable tariffs) + { + ArgumentNullException.ThrowIfNull(tariffs); + + var ends = new Dictionary(); + foreach (var group in tariffs.GroupBy(t => (t.Scope, t.ScopeId, t.Component))) + { + var ordered = group.OrderBy(t => t.ValidFrom).ThenBy(t => t.Id).ToList(); + for (var i = 0; i < ordered.Count; i++) + { + DateOnly? next = i + 1 < ordered.Count ? ordered[i + 1].ValidFrom.AddDays(-1) : null; + ends[ordered[i].Id] = ordered[i].ValidTo is { } to ? (next is { } n && n < to ? n : to) : next; + } + } + + return ends; + } +} diff --git a/src/App/TariffEditing/TariffValue.cs b/src/App/TariffEditing/TariffValue.cs new file mode 100644 index 0000000..823c430 --- /dev/null +++ b/src/App/TariffEditing/TariffValue.cs @@ -0,0 +1,47 @@ +using MeterVault.App.Localization; +using MeterVault.Core.Domain; + +namespace MeterVault.App.TariffEditing; + +/// What the tariff editor says about the value it is about to save (D-38). +public enum TariffValueVerdict +{ + /// A value was entered. + Valid, + + /// No value was entered: nothing can be saved (an untouched field is never a price). + Missing, + + /// + /// An explicit 0 for a price (unit price, base price, feed-in): a valid zero (D-38) that makes its period free — saved, + /// with a note under the field, so a free period is always a choice. + /// + FreeOfCharge, +} + +/// +/// The tariff editor's value rule (D-38, brief §11 "Missing versus free tariff"): a missing price and a free one are +/// different things, so a new tariff starts without a value — the deep link that explains a missing price (D-52) opens it +/// with everything prefilled but the price — and saving needs one; a typed 0 is saved as the valid zero it is, with a note. +/// +public static class TariffValue +{ + /// The verdict on for . + public static TariffValueVerdict Check(double? value, TariffComponent component) => value switch + { + null => TariffValueVerdict.Missing, + 0 when component is TariffComponent.UnitPrice or TariffComponent.BasePrice or TariffComponent.FeedIn => TariffValueVerdict.FreeOfCharge, + _ => TariffValueVerdict.Valid, + }; + + /// True when the dialog must not save. + public static bool BlocksSave(this TariffValueVerdict verdict) => verdict == TariffValueVerdict.Missing; + + /// The note under the value field; null when there is nothing to say. + public static string? Note(TariffValueVerdict verdict) => verdict switch + { + TariffValueVerdict.Missing => Strings.Tariffs_ValueRequired, + TariffValueVerdict.FreeOfCharge => Strings.Tariffs_ValueFreeOfCharge, + _ => null, + }; +} diff --git a/src/App/TariffLinks.cs b/src/App/TariffLinks.cs new file mode 100644 index 0000000..e6ffc2f --- /dev/null +++ b/src/App/TariffLinks.cs @@ -0,0 +1,121 @@ +using System.Globalization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Domain; + +namespace MeterVault.App; + +/// +/// Deep links into the tariff editor (D-52): /admin/tariffs?scope=&id=&component=&from=&action=new +/// opens a new-tariff dialog prefilled for the scope, component and first uncovered month a missing-cost explanation +/// names — so "not priced" is one click from being fixed. +/// +/// +/// The tokens are stable, lower-case identifiers like the analysis keys: scope global|type|meter, component +/// unit-price|base-price|feed-in|bonus|discount|tax, from as yyyy-MM-dd. The parse helpers are what the +/// Tariffs page reads them with; they also accept the enum names (UnitPrice), since those appear in the API. +/// +public static class TariffLinks +{ + public const string Path = "/admin/tariffs"; + + public const string ParamScope = "scope"; + public const string ParamId = "id"; + public const string ParamComponent = "component"; + public const string ParamFrom = "from"; + public const string ParamAction = "action"; + + /// The one-shot action that opens the new-tariff dialog. + public const string ActionNew = "new"; + + private static readonly (TariffScope Value, string Token)[] Scopes = + [ + (TariffScope.Global, "global"), + (TariffScope.EnergyType, "type"), + (TariffScope.Meter, "meter"), + ]; + + private static readonly (TariffComponent Value, string Token)[] Components = + [ + (TariffComponent.UnitPrice, "unit-price"), + (TariffComponent.BasePrice, "base-price"), + (TariffComponent.FeedIn, "feed-in"), + (TariffComponent.Bonus, "bonus"), + (TariffComponent.Discount, "discount"), + (TariffComponent.Tax, "tax"), + ]; + + /// + /// The tariff page with a new tariff prefilled: its scope (and the energy type or meter id; none for global), its + /// component and its first month (written as the month's 1st). + /// + public static string New(TariffScope scope, int? id, TariffComponent component, DateOnly firstMonth) + { + var url = Path + "?" + ParamScope + "=" + ScopeToken(scope); + if (scope != TariffScope.Global && id is { } scopeId) + { + url += "&" + ParamId + "=" + scopeId.ToString(CultureInfo.InvariantCulture); + } + + var month = new DateOnly(firstMonth.Year, firstMonth.Month, 1); + return url + + "&" + ParamComponent + "=" + ComponentToken(component) + + "&" + ParamFrom + "=" + AnalysisTokens.FormatDate(month) + + "&" + ParamAction + "=" + ActionNew; + } + + /// The tariff that would fill a missing price (D-38, D-52): its scope, component and first uncovered month. + public static string For(MissingPrice price) + { + ArgumentNullException.ThrowIfNull(price); + + return New(price.Scope, price.ScopeId, price.Component, price.FirstMonth); + } + + public static string ScopeToken(TariffScope scope) => TokenOf(Scopes, scope, nameof(scope)); + + public static string ComponentToken(TariffComponent component) => TokenOf(Components, component, nameof(component)); + + /// Parses a scope token (global|type|meter) or enum name, ignoring case; false otherwise. + public static bool TryParseScope(string? token, out TariffScope scope) => TryParse(Scopes, token, out scope); + + /// Parses a component token (unit-price, …) or enum name (UnitPrice), ignoring case; false otherwise. + public static bool TryParseComponent(string? token, out TariffComponent component) => TryParse(Components, token, out component); + + private static string TokenOf((T Value, string Token)[] table, T value, string paramName) + where T : struct, Enum + { + foreach (var (candidate, token) in table) + { + if (EqualityComparer.Default.Equals(candidate, value)) + { + return token; + } + } + + throw new ArgumentOutOfRangeException(paramName, value, "No URL token for this value."); + } + + private static bool TryParse((T Value, string Token)[] table, string? token, out T value) + where T : struct, Enum + { + value = default; + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + var text = token.Trim(); + foreach (var (candidate, name) in table) + { + if (string.Equals(name, text, StringComparison.OrdinalIgnoreCase) + || string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase)) + { + value = candidate; + return true; + } + } + + return false; + } +} diff --git a/src/App/Theme/ThemeState.cs b/src/App/Theme/ThemeState.cs new file mode 100644 index 0000000..25e7b54 --- /dev/null +++ b/src/App/Theme/ThemeState.cs @@ -0,0 +1,61 @@ +using Microsoft.JSInterop; + +namespace MeterVault.App.Theme; + +/// +/// The light/dark mode of the current circuit (D-49): one scoped value the layout's theme provider binds to and every +/// chart observes, persisted in the cookie. +/// +/// +/// +/// App.razor reads the cookie on the server and passes the mode to Routes, which calls +/// before anything renders — in the prerender and again when the circuit starts — so a reload +/// and the language switch (a full reload) keep the mode without a flash. Without a cookie the mode is dark, as it +/// always was. +/// +/// +/// Components that draw their own colours (ApexCharts) subscribe to and re-render — typically by +/// keying the chart on — and unsubscribe when disposed. +/// +/// +public sealed class ThemeState(IJSRuntime js) +{ + /// The mode without a stored choice. + public const bool DefaultIsDark = true; + + /// True for the dark palette. + public bool IsDark { get; private set; } = DefaultIsDark; + + /// Raised after the mode changed in this circuit. + public event Action? Changed; + + /// The mode a cookie value names (dark/light, any case), or null for anything else. + public static bool? Parse(string? cookie) => cookie?.Trim().ToLowerInvariant() switch + { + "dark" => true, + "light" => false, + _ => null, + }; + + /// The cookie value of a mode. + public static string Token(bool isDark) => isDark ? "dark" : "light"; + + /// Sets the mode the request arrived with, without raising or writing the cookie. + public void Initialize(bool isDark) => IsDark = isDark; + + /// Switches the mode, tells the observers, and remembers the choice in the cookie. + public async Task SetAsync(bool isDark) + { + if (IsDark == isDark) + { + return; + } + + IsDark = isDark; + Changed?.Invoke(); + await BrowserPreferences.SaveAsync(js, BrowserPreferences.ThemeCookie, Token(isDark)); + } + + /// Switches to the other mode. + public Task ToggleAsync() => SetAsync(!IsDark); +} diff --git a/src/App/wwwroot/app.css b/src/App/wwwroot/app.css index fa65787..127ca9e 100644 --- a/src/App/wwwroot/app.css +++ b/src/App/wwwroot/app.css @@ -2,10 +2,131 @@ h1:focus { outline: none; } -/* MeterVault helpers */ -.mv-up { color: #ef5350; } -.mv-down { color: #66bb6a; } +/* MeterVault helpers. Colours come from the MudBlazor palette variables, so light and dark mode both work. */ +.mv-up { color: var(--mud-palette-error); } +.mv-down { color: var(--mud-palette-success); } .mv-main { max-width: 1400px; } +.mv-muted { color: var(--mud-palette-text-secondary); } + +/* Visually hidden, still read by screen readers. */ +.mv-sr-only { + position: absolute !important; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Page header (Shared/Analysis/PageHeader): title h1 with chips, actions wrapping below on narrow screens. */ +.mv-page-header { margin-bottom: 16px; } +.mv-page-header__row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; } +.mv-page-header__title { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; flex: 1 1 auto; } +.mv-page-header__h1 { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.mv-page-header__chips { display: flex; flex-wrap: wrap; gap: 4px; } +.mv-page-header__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.mv-breadcrumbs .mud-breadcrumbs { flex-wrap: wrap; padding: 0 0 4px 0; } +@media (max-width: 599.98px) { + .mv-page-header__h1 { font-size: 1.5rem; } +} + +/* Period toolbar (Shared/Analysis/PeriodToolbar): controls wrap; each takes a full row on a phone. */ +.mv-toolbar { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px 12px; } +.mv-toolbar__field { flex: 1 1 180px; min-width: 160px; max-width: 280px; } +.mv-toolbar__info { align-self: center; } +.mv-toolbar__details { display: flex; flex-direction: column; gap: 2px; max-width: 280px; } +.mv-toolbar__custom { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; flex: 1 1 100%; } +.mv-toolbar__date { flex: 1 1 160px; max-width: 220px; } +.mv-toolbar__apply { align-self: center; min-height: 40px; } +.mv-toolbar__hint { flex: 1 1 100%; color: var(--mud-palette-error); } +.mv-toolbar__reset, .mv-toolbar__export { align-self: center; } +@media (max-width: 599.98px) { + .mv-toolbar__field, .mv-toolbar__date { max-width: none; flex-basis: 100%; } +} + +/* Changes (Shared/Analysis/ChangeChip): tone by the metric's polarity; the words and the arrow carry the meaning. */ +.mv-change { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; font-size: 0.875rem; line-height: 1.4; } +.mv-change__icon { flex: none; } +.mv-change__caption { color: var(--mud-palette-text-secondary); } +.mv-change-good { color: var(--mud-palette-success); } +.mv-change-bad { color: var(--mud-palette-error); } +.mv-change-neutral { color: var(--mud-palette-text-secondary); } + +/* Metric cards. */ +.mv-metric { height: 100%; } +.mv-metric__value-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; } +.mv-metric__value { font-size: 1.5rem; font-weight: 500; line-height: 1.3; overflow-wrap: anywhere; } +.mv-metric__value--words { font-size: 1.1rem; color: var(--mud-palette-text-secondary); } +.mv-metric__link { display: block; width: fit-content; margin-top: 4px; } + +/* Charts and tables. A wide table scrolls inside its container, never the page. */ +.mv-chart-stack { display: flex; flex-direction: column; gap: 16px; } +.mv-chart { margin: 0; min-width: 0; } +.mv-chart__unit { font-size: 0.75rem; color: var(--mud-palette-text-secondary); } +.mv-chart__note { font-size: 0.75rem; color: var(--mud-palette-text-secondary); margin-top: 4px; } +.mv-chart--clickable .apexcharts-bar-area, +.mv-chart--clickable .apexcharts-marker, +.mv-chart--clickable .apexcharts-xaxis-label { cursor: pointer; } +/* Positioned, so visually hidden cells (absolute) stay inside the scrolling box instead of widening the page. */ +.mv-table-scroll { position: relative; max-width: 100%; overflow-x: auto; } +.mv-table-scroll:focus-visible { outline: 2px solid var(--mud-palette-primary); outline-offset: 2px; } + +/* Visible keyboard focus (brief §8): MudBlazor removes the outline of links and buttons and marks focus only with a ~6 % + tint. Every focusable control gets a solid ring in the text colour of the theme — readable on both the light and the + dark surface (the teal primary is under 3:1 on white). Nav links are drawn inside, so the drawer never clips the ring. */ +.mud-button-root:focus-visible, +.mud-icon-button:focus-visible, +.mud-icon-button:has(:focus-visible), +.mud-chip:focus-visible, +.mud-link:focus-visible, +.mud-tab:focus-visible, +.mud-breadcrumb-item a:focus-visible, +.mv-drill a:focus-visible { + outline: 2px solid var(--mud-palette-text-primary); + outline-offset: 2px; +} +.mud-nav-link:focus-visible, +.mud-nav-group > button:focus-visible, +.mud-list-item:focus-visible { + outline: 2px solid var(--mud-palette-text-primary); + outline-offset: -2px; +} +.mv-analysis-table th, .mv-analysis-table td { white-space: nowrap; } +.mv-analysis-table td .mv-cell-secondary { white-space: normal; min-width: 12ch; } +.mv-num { text-align: right; font-variant-numeric: tabular-nums; } +.mv-cell-secondary { font-size: 0.75rem; color: var(--mud-palette-text-secondary); } +.mv-row-label { font-weight: 400; text-align: left; } +.mv-row-total th, .mv-row-total td { font-weight: 600; border-top: 2px solid var(--mud-palette-lines-default); } +.mv-qualified { font-style: italic; } +.mv-unknown { color: var(--mud-palette-text-secondary); } +.mv-drill { width: 1%; } + +/* States: empty, refreshing, projection, comparison, attention, contributions. */ +.mv-empty { display: flex; gap: 12px; align-items: flex-start; padding: 16px; border: 1px dashed var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius); } +.mv-empty__icon { color: var(--mud-palette-text-secondary); flex: none; } +.mv-refresh__bar { min-height: 4px; } +.mv-refresh--busy .mv-refresh__content { opacity: 0.55; transition: opacity 0.2s ease-in; } +.mv-projection { display: inline-flex; align-items: center; gap: 6px; font-size: 0.875rem; color: var(--mud-palette-text-secondary); } +.mv-comparison { display: flex; flex-direction: column; gap: 2px; font-size: 0.875rem; } +.mv-comparison__warning { display: inline-flex; align-items: center; gap: 4px; } +.mv-attention__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.mv-attention__item { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; } +.mv-attention__icon { flex: none; align-self: center; } +.mv-attention__item--error .mv-attention__icon { color: var(--mud-palette-error); } +.mv-attention__item--warning .mv-attention__icon { color: var(--mud-palette-warning); } +.mv-attention__item--info .mv-attention__icon { color: var(--mud-palette-info); } +.mv-attention__text { flex: 1 1 16rem; min-width: 0; overflow-wrap: anywhere; } +.mv-contributions__formula { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; } +.mv-formula { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 4px; } +.mv-formula code { font-size: 0.8rem; color: var(--mud-palette-text-secondary); } +.mv-formula__ref { display: inline-flex; align-items: baseline; gap: 4px; } + +@media (prefers-reduced-motion: reduce) { + .mv-refresh--busy .mv-refresh__content { transition: none; } +} .valid.modified:not([type=checkbox]) { outline: 1px solid #26b050; diff --git a/src/App/wwwroot/metervault.js b/src/App/wwwroot/metervault.js new file mode 100644 index 0000000..9b30aed --- /dev/null +++ b/src/App/wwwroot/metervault.js @@ -0,0 +1,19 @@ +// MeterVault's own browser helpers. Kept tiny and declarative: no eval, no dynamic code, nothing but what the +// server-side components call through IJSRuntime. +window.meterVault = window.meterVault || {}; + +// UI preferences the server reads on the next request (App.razor): the theme and the expanded navigation groups. +// Only these cookie names are accepted, so a component cannot be used to set anything else. +(function () { + const allowed = ["mv-theme", "mv-nav"]; + const maxAge = 60 * 60 * 24 * 365; // one year + + window.meterVault.setPreference = function (name, value) { + if (allowed.indexOf(name) < 0) { + return false; + } + + document.cookie = name + "=" + encodeURIComponent(String(value)) + "; path=/; max-age=" + maxAge + "; samesite=lax"; + return true; + }; +})(); diff --git a/src/Core/Analysis/AnalysisContracts.cs b/src/Core/Analysis/AnalysisContracts.cs new file mode 100644 index 0000000..1dc4c32 --- /dev/null +++ b/src/Core/Analysis/AnalysisContracts.cs @@ -0,0 +1,262 @@ +namespace MeterVault.Core.Analysis; + +// Shared vocabulary of the analysis layer (docs/ANALYSIS_IMPLEMENTATION_NOTE.md). Every module — period +// resolution, coverage, virtual evaluation, totals, costing and the Infrastructure reader — speaks these +// types, so a value keeps its meaning from the SQL row to the chart tooltip. Keep this file small and +// stable: behaviour lives in the modules, not here. + +/// A relative or custom analysis period (D-02). URL tokens: mtd, last-month, ytd, prev-year, 12m, 24m, all, custom. +public enum PeriodPreset +{ + MonthToDate, + LastMonth, + YearToDate, + PreviousYear, + Last12Months, + Last24Months, + AllHistory, + Custom, +} + +/// Bucket size of a series (D-05). URL tokens: auto, day, week, month, year. +public enum BucketSize +{ + Auto, + Day, + Week, + Month, + Year, +} + +/// What a period is compared against (D-06). URL tokens: none, prev-period, prev-year, year:YYYY. +public enum ComparisonKind +{ + None, + PreviousPeriod, + PreviousYear, + + /// A named calendar year (); needs a year-aligned range. + Year, +} + +public sealed record ComparisonRequest(ComparisonKind Kind, int? Year = null) +{ + public static readonly ComparisonRequest None = new(ComparisonKind.None); +} + +/// +/// A period resolved once in the instance timezone (D-03): an inclusive local date range for display and a +/// half-open UTC range for every query. For a to-date period is the captured +/// ; otherwise it is the local midnight after . +/// +public sealed record ResolvedPeriod( + PeriodPreset Preset, + DateOnly FirstDay, + DateOnly LastDay, + DateTimeOffset From, + DateTimeOffset To, + DateTimeOffset Now, + bool IsToDate, + bool ExtendsPastNow, + TimeZoneInfo Zone) +{ + /// + /// True when the whole requested range lies after now: nothing has happened yet (D-04). A to-date period at the + /// very instant it starts (00:00 on the 1st for month to date) has not "not occurred" — it has simply had no time + /// yet, and gets its buckets like any other to-date period. + /// + public bool NotYetOccurred => !IsToDate && FirstDay <= LastDay && From >= Now; +} + +/// +/// One bucket of a series: local dates (end exclusive) and the UTC instants they map to, clipped to the period. +/// is the end of the whole calendar unit, clipped to the period's named range, when the +/// bucket was cut short (the current, to-date month of a month series): drilling into such a bucket opens the whole +/// unit, so its comparison stays the same as the parent's. +/// +public sealed record AnalysisBucket( + DateOnly FirstDay, + DateOnly EndDay, + DateTimeOffset From, + DateTimeOffset To, + BucketSize Size, + DateOnly? NominalEndDay = null) +{ + /// True when the bucket stops before the end of its calendar unit (cut at now or at the period end). + public bool IsCutShort => NominalEndDay is { } nominal && nominal > EndDay; +} + +/// Whether a bucket's value can be trusted (D-14). Provenance is a separate dimension. +public enum BucketStatus +{ + /// Fully covered at a sufficient resolution; a zero is a real zero. + Available, + + /// Only part of the bucket is covered; the value is a partial total. + Partial, + + /// Nothing covers the bucket; the value is unknown. + Missing, + + /// Covered, but only at a coarser resolution than the bucket (e.g. monthly data asked by day). + Unresolved, + + /// A calculation could not be evaluated (division by zero, invalid formula, broken dependency). + Invalid, + + /// The meter's analysis data is being (re)built after an upgrade or zone change. + Pending, +} + +/// Where a value comes from (brief §4.3). Combinable: a derived value can also rest on estimated input. +[Flags] +public enum Provenance +{ + None = 0, + Measured = 1, + Manual = 2, + Imported = 4, + + /// Divided across months, coalesced, or otherwise inferred from a total. + Estimated = 8, + + /// Calculated from other meters (virtual). + Derived = 16, + + /// Contains a first reading booked against the baseline with an unknown start (D-14). + OpeningBalance = 32, +} + +/// +/// Why a value is not a plain available number — a code the UI localizes. The accompanying detail is data +/// (a meter name, a date), never prose. +/// +public enum ValueIssue +{ + None, + NoCoverage, + PartialCoverage, + CoarseResolution, + OpeningBalance, + RegisterDiscontinuity, + SampleGap, + MissingSource, + NonFinite, + InvalidDefinition, + DependencyCycle, + + /// An expression-less legacy virtual meter evaluated as its implied sum until it is confirmed (D-28). + LegacyDefinition, + NotPriced, + PriceGap, + UnitMismatch, + AnalysisPending, + NotYetOccurred, + + /// + /// Rows inside the bucket close after now and are left out of it (D-04, A-05): what it shows so far is not the + /// whole of it, even where coverage reaches its end (A-20). + /// + RecordedAfterNow, +} + +/// +/// One bucket's value. is null whenever the status makes a number meaningless +/// (missing, unresolved, invalid, pending); a partial bucket keeps its partial total. +/// +/// +/// For a derived value whose issue comes from a dependency, the meter ids from this meter down to the one that +/// caused it (e.g. [9, 5] when Summe Solar lacks Solar 2's data). Kept apart from , which is +/// free data such as a date. +/// +public sealed record BucketValue( + double? Value, + BucketStatus Status, + Provenance Provenance, + ValueIssue Issue = ValueIssue.None, + string? IssueDetail = null, + IReadOnlyList? DependencyPath = null) +{ + public static BucketValue Missing(ValueIssue issue = ValueIssue.NoCoverage, string? detail = null) => + new(null, BucketStatus.Missing, Provenance.None, issue, detail); + + public static BucketValue Available(double value, Provenance provenance) => + new(value, BucketStatus.Available, provenance); +} + +/// What a series measures (D-20). Cost series use . +public enum QuantityKind +{ + Consumption, + Generation, + Export, + Runtime, + + /// A signed virtual result mixing kinds, declared explicitly (D-26). + Net, + + /// A non-additive virtual ratio or product, declared explicitly; never totalled or costed. + Indicator, + + Cost, +} + +/// +/// How finely a meter's intervals resolve time (D-13): the longest interval in a run, in classes. A bucket +/// can only be resolved by runs whose class is no coarser than the bucket. +/// +public enum ResolutionClass +{ + /// Intervals of at most about an hour. + Hour = 0, + + /// At most about a day (a DST day is 25 h). + Day = 1, + + /// At most about a week. + Week = 2, + + /// At most one local calendar month. + Month = 3, + + /// Longer than a month. + Coarse = 4, +} + +/// Why a stretch of a meter's timeline is a known hole rather than covered time (D-13). +public enum CoverageGapReason +{ + None, + UnexplainedDecrease, + ResetWithoutPrevious, + SampleGap, +} + +/// +/// A run of consecutive source intervals of one resolution class (D-13). A run with a other +/// than is a known hole, not coverage. +/// +/// +/// True when no interval of the run straddles a local month boundary undivided: each one was either divided +/// at month boundaries by the normalizer (counters) or lies inside one local month. Month and year buckets are +/// then resolvable whatever the run's class; week and day buckets still need the class. +/// +/// +/// Where the run's final interval starts. Runs are stored uncapped; a reader cutting coverage at "now" ends it +/// here when now falls inside that final interval, because the row closing it is recorded after now (D-04) — +/// a current-month label row covers the whole month and must not make month-to-date look covered. +/// +/// +/// An opening balance (a first reading with unknown start) is not a run: it has no interval. It travels as a +/// flag on the rollup day it is booked in. +/// +public sealed record CoverageRun( + DateTimeOffset From, + DateTimeOffset To, + ResolutionClass Resolution, + bool DividedAtMonths, + CoverageGapReason Gap = CoverageGapReason.None, + DateTimeOffset? LastIntervalStart = null) +{ + public bool IsGap => Gap != CoverageGapReason.None; +} diff --git a/src/Core/Analysis/Costing/CostAmount.cs b/src/Core/Analysis/Costing/CostAmount.cs new file mode 100644 index 0000000..5f56d89 --- /dev/null +++ b/src/Core/Analysis/Costing/CostAmount.cs @@ -0,0 +1,386 @@ +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Costing; + +/// +/// A cost figure — one line in one bucket, a standing-charge row, a bucket's total, a whole bill — with its price +/// coverage () and the availability of the quantities behind it kept apart (brief §4.3). +/// +/// +/// +/// Figures add exactly: of the months gives the same figure as pricing the year at once, and the sum +/// of lines gives the bill. That is why the figure keeps what it was built from rather than only its status. +/// +/// +/// The status rules (D-38), applied to everything folded into a figure: +/// +/// A component whose scope has no tariff at any date is : an attention item +/// that is left out of the value and never makes a total partial. Only a figure made of nothing else is not +/// priced. +/// A month inside a priced scope's history without a price, or with a tariff that does not fit, is unknown: the +/// figure is when something else in it was priced, otherwise +/// or (the mismatch wins, being the +/// configuration error to fix first). +/// A known zero quantity needs no price: zero is zero at any price, so it never reports a gap. Nor does a +/// month without any data (): nothing was measured, so nothing needs a price. +/// +/// +/// +/// All amounts are in the instance currency, unrounded. A feed-in credit is kept as a positive amount apart from the +/// charges; is what the figure costs after the credit. +/// +/// +public sealed record CostAmount +{ + private readonly double? _usage; + private readonly double? _standing; + private readonly double? _manual; + private readonly double? _credit; + + internal CostAmount( + double? usage, + double? standing, + double? manual, + double? credit, + CostFlags flags, + AvailabilitySeen seen, + IReadOnlyList missingPrices) + { + _usage = usage; + _standing = standing; + _manual = manual; + _credit = credit; + Flags = flags; + Seen = seen; + MissingPrices = missingPrices; + Status = StatusOf(flags); + Availability = AvailabilityOf(seen); + } + + /// A figure with nothing in it: priced, no value, nothing missing. + public static CostAmount Empty { get; } = new(null, null, null, null, CostFlags.None, AvailabilitySeen.None, []); + + /// How much of the figure could be priced (D-38). + public CostStatus Status { get; } + + /// + /// How available the priced quantities are (D-14), combined as a sum: when + /// every quantity is, when some value is known but not all, otherwise the + /// reason nothing is (pending, invalid, unresolved, missing — in that order). Standing charges and manual costs + /// are always available. Quantities left out as do not count. + /// + public BucketStatus Availability { get; } + + /// + /// True when a component was left out because its scope has no tariff at any date (D-38): the value is complete + /// for everything priced, and the attention item names what was left out. + /// + public bool IncludesNotPriced => Flags.HasFlag(CostFlags.NotPriced); + + /// + /// True when a tariff whose unit could not be checked was applied (D-37), or a manual cost in another currency + /// was booked: show a warning next to the value. + /// + public bool Unverified => Flags.HasFlag(CostFlags.Unverified); + + /// The prices this figure needed and did not get, with the months that lack them. + public IReadOnlyList MissingPrices { get; } + + /// + /// The priced quantity × unit price (UnitPrice and OwnPrice lines), or null when no quantity was priced. A known + /// zero quantity counts as priced (0). + /// + public double? Usage => HasValue ? _usage : null; + + /// Standing charges accrued (D-40), or null when none applies. + public double? StandingCharge => HasValue ? _standing : null; + + /// Manual costs booked (D-41), or null when none. + public double? Manual => HasValue ? _manual : null; + + /// The feed-in credit (export × feed-in price, D-34) as a positive amount, or null when none was priced. + public double? FeedInCredit => HasValue ? _credit : null; + + /// Everything charged — usage, standing charges and manual costs — before the credit; null when none is known. + public double? Charges => HasValue ? AddKnown(AddKnown(_usage, _standing), _manual) : null; + + /// + /// What the figure costs: less ; negative when the credit is larger. + /// Null when nothing could be priced — for , + /// and , and when the quantities are unknown — never a fabricated zero (A05). + /// A figure keeps its priced part. + /// + public double? Cost + { + get + { + var charges = Charges; + var credit = FeedInCredit; + if (charges is null && credit is null) + { + return null; + } + + return (charges ?? 0) - (credit ?? 0); + } + } + + internal CostFlags Flags { get; } + + internal AvailabilitySeen Seen { get; } + + internal double? RawUsage => _usage; + + internal double? RawStanding => _standing; + + internal double? RawManual => _manual; + + internal double? RawCredit => _credit; + + private bool HasValue => Status is CostStatus.Priced or CostStatus.Partial; + + /// + /// Adds figures exactly: amounts add, statuses combine by the rules above, missing prices merge into one entry per + /// price with the earliest and latest month. The composition of a bill, a category (D-42) or a virtual meter's + /// source costs (D-39) is the sum of its lines' figures. + /// + public static CostAmount Sum(IEnumerable amounts) + { + ArgumentNullException.ThrowIfNull(amounts); + + var accumulator = new CostAccumulator(); + foreach (var amount in amounts) + { + ArgumentNullException.ThrowIfNull(amount, nameof(amounts)); + accumulator.Add(amount); + } + + return accumulator.Build(); + } + + internal static double? AddKnown(double? left, double? right) => + left is null ? right : right is null ? left : left + right; + + private static CostStatus StatusOf(CostFlags flags) + { + var priced = flags.HasFlag(CostFlags.Priced); + var gap = flags.HasFlag(CostFlags.Gap); + var mismatch = flags.HasFlag(CostFlags.Mismatch); + if (gap || mismatch) + { + return priced ? CostStatus.Partial : mismatch ? CostStatus.UnitMismatch : CostStatus.PriceGap; + } + + return !priced && flags.HasFlag(CostFlags.NotPriced) ? CostStatus.NotPriced : CostStatus.Priced; + } + + private static BucketStatus AvailabilityOf(AvailabilitySeen seen) + { + const AvailabilitySeen unusable = AvailabilitySeen.Missing | AvailabilitySeen.Unresolved | AvailabilitySeen.Invalid | AvailabilitySeen.Pending; + if (seen == AvailabilitySeen.None || seen == AvailabilitySeen.Available) + { + return BucketStatus.Available; + } + + if ((seen & (AvailabilitySeen.Available | AvailabilitySeen.Partial)) != 0) + { + return BucketStatus.Partial; + } + + return (seen & unusable) switch + { + var s when s.HasFlag(AvailabilitySeen.Pending) => BucketStatus.Pending, + var s when s.HasFlag(AvailabilitySeen.Invalid) => BucketStatus.Invalid, + var s when s.HasFlag(AvailabilitySeen.Unresolved) => BucketStatus.Unresolved, + _ => BucketStatus.Missing, + }; + } +} + +/// What was folded into a ; its status is derived from these, so sums stay exact. +[Flags] +internal enum CostFlags +{ + None = 0, + + /// Something was priced with a price (a tariff, an accrued standing charge, a booked manual cost). + Priced = 1, + + /// A quantity to price had no tariff in its scope at any date. + NotPriced = 2, + + /// A quantity to price fell in a month without a price inside a priced scope. + Gap = 4, + + /// A quantity to price met a tariff that does not fit. + Mismatch = 8, + + /// A tariff with an unchecked unit, or a manual cost in another currency, contributed. + Unverified = 16, +} + +/// The quantity availabilities folded into a . +[Flags] +internal enum AvailabilitySeen +{ + None = 0, + Available = 1, + Partial = 2, + Missing = 4, + Unresolved = 8, + Invalid = 16, + Pending = 32, +} + +/// Which amount of a figure a contribution belongs to. +internal enum CostComponent +{ + Usage, + Standing, + Manual, + Credit, +} + +/// Builds a from priced parts and other figures. +internal sealed class CostAccumulator +{ + private readonly Dictionary _missing = []; + private double? _usage; + private double? _standing; + private double? _manual; + private double? _credit; + private CostFlags _flags; + private AvailabilitySeen _seen; + + /// A priced contribution; is null when the quantity behind it is unknown. + public void AddPriced(CostComponent component, double? value, BucketStatus? availability, bool unverified) + { + _flags |= CostFlags.Priced | (unverified ? CostFlags.Unverified : CostFlags.None); + See(availability); + if (value is { } known) + { + AddValue(component, known); + } + } + + /// Nothing to price (a known zero quantity, a standing charge with no day in service): a known 0. + public void AddNothing(CostComponent component, BucketStatus? availability) + { + See(availability); + AddValue(component, 0); + } + + /// A quantity nothing is known about and nothing needs pricing for: only its availability counts. + public void AddUnknown(BucketStatus availability) => See(availability); + + /// A quantity whose scope has no tariff at any date: left out, and reported. + public void AddNotPriced(MissingPrice missing) + { + _flags |= CostFlags.NotPriced; + AddMissing(missing); + } + + /// A quantity in a month without a price () or with one that does not fit. + public void AddUnavailable(MissingPrice missing, BucketStatus? availability) + { + _flags |= missing.Reason == CostStatus.UnitMismatch ? CostFlags.Mismatch : CostFlags.Gap; + See(availability); + AddMissing(missing); + } + + /// Folds a finished figure in. + public void Add(CostAmount amount) + { + _usage = CostAmount.AddKnown(_usage, amount.RawUsage); + _standing = CostAmount.AddKnown(_standing, amount.RawStanding); + _manual = CostAmount.AddKnown(_manual, amount.RawManual); + _credit = CostAmount.AddKnown(_credit, amount.RawCredit); + _flags |= amount.Flags; + _seen |= amount.Seen; + foreach (var missing in amount.MissingPrices) + { + AddMissing(missing); + } + } + + public CostAmount Build() + { + List missing = + [ + .. _missing + .Select(m => m.Key.ToMissingPrice(m.Value.First, m.Value.Last)) + .OrderBy(m => m.FirstMonth) + .ThenBy(m => m.MeterId ?? int.MaxValue) + .ThenBy(m => m.Component) + .ThenBy(m => m.Reason) + .ThenBy(m => m.Scope) + .ThenBy(m => m.ScopeId ?? int.MinValue) + .ThenBy(m => m.TariffId ?? int.MinValue) + .ThenBy(m => m.Issue), + ]; + return new CostAmount(_usage, _standing, _manual, _credit, _flags, _seen, missing); + } + + private void AddValue(CostComponent component, double value) + { + switch (component) + { + case CostComponent.Usage: + _usage = (_usage ?? 0) + value; + break; + case CostComponent.Standing: + _standing = (_standing ?? 0) + value; + break; + case CostComponent.Manual: + _manual = (_manual ?? 0) + value; + break; + default: + _credit = (_credit ?? 0) + value; + break; + } + } + + private void See(BucketStatus? availability) + { + _seen |= availability switch + { + null => AvailabilitySeen.None, + BucketStatus.Available => AvailabilitySeen.Available, + BucketStatus.Partial => AvailabilitySeen.Partial, + BucketStatus.Unresolved => AvailabilitySeen.Unresolved, + BucketStatus.Invalid => AvailabilitySeen.Invalid, + BucketStatus.Pending => AvailabilitySeen.Pending, + _ => AvailabilitySeen.Missing, + }; + } + + private void AddMissing(MissingPrice missing) + { + var key = MissingPriceKey.Of(missing); + _missing[key] = _missing.TryGetValue(key, out var months) + ? (Min(months.First, missing.FirstMonth), Max(months.Last, missing.LastMonth)) + : (missing.FirstMonth, missing.LastMonth); + } + + private static DateOnly Min(DateOnly a, DateOnly b) => a <= b ? a : b; + + private static DateOnly Max(DateOnly a, DateOnly b) => a >= b ? a : b; + + /// A missing price without its months: entries with the same key merge. + private readonly record struct MissingPriceKey( + TariffComponent Component, + CostStatus Reason, + TariffScope Scope, + int? ScopeId, + int? MeterId, + int? TariffId, + TariffUnitIssue Issue) + { + public static MissingPriceKey Of(MissingPrice m) => + new(m.Component, m.Reason, m.Scope, m.ScopeId, m.MeterId, m.TariffId, m.Issue); + + public MissingPrice ToMissingPrice(DateOnly first, DateOnly last) => + new(Component, Reason, Scope, ScopeId, MeterId, first, last, TariffId, Issue); + } +} diff --git a/src/Core/Analysis/Costing/CostCalculator.cs b/src/Core/Analysis/Costing/CostCalculator.cs new file mode 100644 index 0000000..00ccfbb --- /dev/null +++ b/src/Core/Analysis/Costing/CostCalculator.cs @@ -0,0 +1,664 @@ +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Costing; + +/// +/// The pure pricing rules of the cost engine (D-34 – D-41, D-43): prices bill lines, standing charges and manual +/// costs per bucket, and says exactly what could not be priced and why. +/// +/// +/// +/// Months (D-36). Every bucket is cut into its local months () and every part is priced with +/// the price in effect on the 15th of its own month — the monthly convention of the spreadsheet. A week straddling a +/// month boundary is priced half with each month's price, a year is the sum of its months, so changing the bucket +/// never changes a total. The caller reads each line's quantity per part. +/// +/// +/// Lines (D-34, D-35). The calculator is topology-free: it prices the lines of a bill +/// () with the net quantities the caller measured — a billed meter already less its +/// separately billed subsections. A line takes the unit price by the normal +/// precedence (meter, energy type, global); an line only its own meter-scoped unit +/// price, and in a month without one it is not billed separately () and gets +/// nothing; a line earns the feed-in price on its export, kept as a credit apart +/// from the charges. Generation is never a line, so it never earns a credit. +/// +/// +/// Units and currency (D-37, D-43). A price applies only when its unit fits the line's normalized unit, scaled +/// (ct, per 100 L, per MWh). The tariff in effect is found first and checked second: a mismatching tariff makes the +/// month "unavailable (unit)" rather than falling back to a lower scope, which would silently apply a price the user +/// overrode. A unit that cannot be read applies at face value with a warning. A unit in another currency than the +/// instance's is a mismatch. +/// +/// +/// Coverage (D-38). A line whose scope has no tariff of its component at any date is not priced: an attention +/// item left out of the value, never a reason for a total to be partial. A month without a price inside a priced +/// scope's history is a gap: that month's cost is unavailable. An explicit zero tariff prices a valid zero, and a +/// known zero quantity needs no price at all. Neither does a month the line has no data for +/// (): its cost is unknown because nothing was measured, so no price is reported +/// missing for it — the months before a meter's first reading never ask for a tariff. A missing feed-in price is only +/// reported for a feed-in line with export. +/// +/// +/// Standing charges (D-40). A base price accrues per local day up to today, at its value spread over the days +/// of its period (month, quarter, year; a daily one as is — ), +/// over the scope's service period whatever its reading gaps, with the price of each day's month. A meter-scoped +/// charge accrues on its meter's line, or as a row of its own for a meter without a line (A-18); a type- or +/// global-scoped one is its own row, once per day, never split across meters. Each scope's charge is its own: a +/// meter's own standing charge (a heat-pump meter fee) does not replace the type's. +/// +/// +/// Spans (A-16). A reading interval longer than a month leaves every month it touches unresolved, so no month +/// can be priced. A line may give its quantity over whole buckets (); a bucket whose months +/// with data all have the same price is then priced as a whole. A price change inside it leaves it unknown, and says +/// so (). +/// +/// +/// Manual costs (D-41) are booked in full on their local day when that day +/// lies in a bucket and is not after today; is informational. +/// +/// +/// Virtual meters (D-39) need no rule of their own: ownQuantity is a +/// line for the virtual meter with its evaluated quantity and result unit; sourceCosts is a request with a +/// line per source and no standing-charge scopes, whose total is the virtual cost. +/// +/// +public static class CostCalculator +{ + /// + /// Cuts buckets into their local months (D-36): the parts a caller reads quantities for and the calculator prices. + /// In bucket order, each bucket's parts in day order. + /// + /// A bucket ends before it starts, or two buckets overlap. + public static IReadOnlyList Parts(IReadOnlyList buckets) + { + ArgumentNullException.ThrowIfNull(buckets); + CheckBuckets(buckets); + + var parts = new List(); + for (var i = 0; i < buckets.Count; i++) + { + var bucket = buckets[i]; + var day = bucket.FirstDay; + while (day < bucket.EndDay) + { + var nextMonth = new DateOnly(day.Year, day.Month, 1).AddMonths(1); + var end = nextMonth < bucket.EndDay ? nextMonth : bucket.EndDay; + parts.Add(new CostPart(i, day, end)); + day = end; + } + } + + return parts; + } + + /// Prices a request. + /// + /// A bucket or quantity is malformed: overlapping buckets, a quantity that is not exactly a part of the request or + /// is given twice, a non-finite amount, or a standing-charge scope that is not a type or global scope (or is given + /// twice). + /// + public static CostResult Calculate(CostRequest request) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Buckets, nameof(request)); + ArgumentNullException.ThrowIfNull(request.Tariffs, nameof(request)); + ArgumentNullException.ThrowIfNull(request.Lines, nameof(request)); + + return new CostRun(request, Parts(request.Buckets)).Execute(); + } + + private static void CheckBuckets(IReadOnlyList buckets) + { + foreach (var bucket in buckets) + { + ArgumentNullException.ThrowIfNull(bucket, nameof(buckets)); + if (bucket.EndDay < bucket.FirstDay) + { + throw new ArgumentException( + $"A bucket ends ({bucket.EndDay:yyyy-MM-dd}) before it starts ({bucket.FirstDay:yyyy-MM-dd}).", nameof(buckets)); + } + } + + var ordered = buckets.Where(b => b.EndDay > b.FirstDay).OrderBy(b => b.FirstDay).ToList(); + for (var i = 1; i < ordered.Count; i++) + { + if (ordered[i].FirstDay < ordered[i - 1].EndDay) + { + throw new ArgumentException( + $"Buckets overlap at {ordered[i].FirstDay:yyyy-MM-dd}; each day may be priced once.", nameof(buckets)); + } + } + } +} + +/// One call: the request, its parts and the caches of the call. +internal sealed class CostRun +{ + private readonly CostRequest _request; + private readonly TariffBook _book; + private readonly IReadOnlyList _parts; + private readonly Dictionary _partsByFirstDay; + private readonly int _todayNumber; + private readonly Dictionary<(Tariff Tariff, string Unit), TariffApplicability> _fits = []; + private readonly Dictionary _accruals = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary<(TariffScope Scope, int? ScopeId, DateOnly Month), Tariff?> _baseTariffs = []; + private readonly Dictionary _warnings = []; + + public CostRun(CostRequest request, IReadOnlyList parts) + { + _request = request; + _book = request.Tariffs; + _parts = parts; + _partsByFirstDay = parts.ToDictionary(p => p.FirstDay); + _todayNumber = request.Today.DayNumber; + } + + private enum Outcome + { + Priced, + InParent, + NotPriced, + Gap, + Mismatch, + } + + public CostResult Execute() + { + var lines = _request.Lines.Select(PriceLine).ToList(); + var rows = StandingScopes().Select(PriceRow).ToList(); + var manual = BookManualCosts(); + + var totals = new List(_request.Buckets.Count); + for (var b = 0; b < _request.Buckets.Count; b++) + { + var bucket = b; + totals.Add(CostAmount.Sum( + lines.Select(l => l.Buckets[bucket]) + .Concat(rows.Select(r => r.Buckets[bucket])) + .Append(manual.Buckets[bucket]))); + } + + List warnings = + [ + .. _warnings + .Select(w => new TariffWarning(w.Key.TariffId, w.Key.Component, w.Key.Issue, w.Key.MeterId, w.Value)) + .OrderBy(w => w.FirstMonth) + .ThenBy(w => w.TariffId) + .ThenBy(w => w.MeterId ?? int.MaxValue), + ]; + + return new CostResult(_book.Currency, _request.Buckets, lines, rows, manual, totals, CostAmount.Sum(totals), warnings); + } + + private CostLineResult PriceLine(CostLine line) + { + ArgumentNullException.ThrowIfNull(line, nameof(_request.Lines)); + ArgumentNullException.ThrowIfNull(line.Quantities, nameof(_request.Lines)); + + var quantities = IndexQuantities(line); + var cells = NewCells(); + var standing = NewCells(); + var withoutOwnPrice = new SortedSet(); + var component = line.Kind == BillLineKind.FeedIn ? TariffComponent.FeedIn : TariffComponent.UnitPrice; + var target = line.Kind == BillLineKind.FeedIn ? CostComponent.Credit : CostComponent.Usage; + var scopePriced = line.Kind == BillLineKind.OwnPrice + ? _book.HasAnyInScope(TariffComponent.UnitPrice, TariffScope.Meter, line.MeterId) + : _book.HasAny(component, line.MeterId, line.EnergyTypeId); + var hasStandingCharge = _book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Meter, line.MeterId); + var prices = new Dictionary(); + var priced = new List<(CostPart Part, LinePrice Price, CostQuantity? Quantity)>(_parts.Count); + + foreach (var part in _parts) + { + if (!prices.TryGetValue(part.Month, out var price)) + { + price = PriceOf(line, component, scopePriced, part.Month); + prices[part.Month] = price; + } + + var quantity = quantities.GetValueOrDefault(part.FirstDay); + priced.Add((part, price, quantity)); + if (price.Outcome == Outcome.InParent) + { + withoutOwnPrice.Add(part.Month); + } + else + { + AddQuantity(cells[part.BucketIndex], line, component, target, price, quantity, part); + } + + if (hasStandingCharge) + { + AddStandingCharge(standing[part.BucketIndex], TariffScope.Meter, line.MeterId, line.Service, part, line.MeterId); + } + } + + var priceChanges = new SortedSet(); + foreach (var span in line.Spans ?? []) + { + PriceSpan(cells, span, priced, target, priceChanges); + } + + var buckets = cells.Select((c, i) => CostAmount.Sum([c.Build(), standing[i].Build()])).ToList(); + return new CostLineResult( + line.MeterId, + line.EnergyTypeId, + line.Kind, + line.Unit ?? string.Empty, + buckets, + CostAmount.Sum(buckets), + [.. withoutOwnPrice]) + { + MonthsWithPriceChangeInsideInterval = [.. priceChanges], + }; + } + + /// + /// A-16: a bucket of several parts, one of them unresolved (a reading interval longer than a month lies in it), is + /// priced as a whole from when one price covers every month the bucket has data in: the + /// quantity at that price is what the months would cost together, whatever their split. When the price differs + /// between those months, the bucket keeps its unknown cost and its months are reported. + /// + private static void PriceSpan( + CostAccumulator[] cells, + CostSpan span, + List<(CostPart Part, LinePrice Price, CostQuantity? Quantity)> priced, + CostComponent target, + SortedSet priceChanges) + { + if (span is null) + { + throw new ArgumentException("A line's spans may not contain null.", nameof(span)); + } + + if (span.BucketIndex < 0 || span.BucketIndex >= cells.Length) + { + throw new ArgumentException($"A span names bucket {span.BucketIndex}, which the request does not have.", nameof(span)); + } + + var parts = priced.Where(p => p.Part.BucketIndex == span.BucketIndex).ToList(); + if (parts.Count < 2 || !span.IsKnown || !parts.Exists(p => p.Quantity?.Availability == BucketStatus.Unresolved)) + { + return; + } + + var withData = parts.Where(p => (p.Quantity?.Availability ?? BucketStatus.Missing) != BucketStatus.Missing).ToList(); + if (!withData.TrueForAll(p => p.Price.Outcome == Outcome.Priced)) + { + // A gap or a mismatch is already reported per month; a month without an own price is not this line's. + return; + } + + var first = withData[0].Price; + if (!withData.TrueForAll(p => p.Price.PerUnit.Equals(first.PerUnit) && p.Price.Unverified == first.Unverified)) + { + priceChanges.UnionWith(withData.Select(p => p.Part.Month)); + return; + } + + var cell = new CostAccumulator(); + cell.AddPriced(target, span.Amount!.Value * first.PerUnit, span.Availability, first.Unverified); + cells[span.BucketIndex] = cell; + } + + private StandingChargeResult PriceRow(StandingChargeScope scope) + { + var cells = NewCells(); + var meterId = scope.Scope == TariffScope.Meter ? scope.ScopeId : null; + foreach (var part in _parts) + { + AddStandingCharge(cells[part.BucketIndex], scope.Scope, scope.ScopeId, scope.Service, part, meterId); + } + + var buckets = cells.Select(c => c.Build()).ToList(); + return new StandingChargeResult(scope.Scope, scope.ScopeId, buckets, CostAmount.Sum(buckets)); + } + + /// The requested scopes that have a base price at any date; the others add no row. + private List StandingScopes() + { + var scopes = _request.StandingCharges ?? []; + var seen = new HashSet<(TariffScope, int?)>(); + foreach (var scope in scopes) + { + ArgumentNullException.ThrowIfNull(scope, nameof(_request.StandingCharges)); + var valid = scope.Scope switch + { + TariffScope.EnergyType or TariffScope.Meter => scope.ScopeId is not null, + TariffScope.Global => scope.ScopeId is null, + _ => false, + }; + if (!valid) + { + throw new ArgumentException( + $"A standing-charge row is an energy type or a meter (with its id) or the global scope (without one), not {scope.Scope} {scope.ScopeId}.", + nameof(_request.StandingCharges)); + } + + if (scope.Scope == TariffScope.Meter && _request.Lines.Any(l => l?.MeterId == scope.ScopeId)) + { + throw new ArgumentException( + $"Meter {scope.ScopeId} has a line in the request, and its own standing charge accrues on that line; it is not a row too.", + nameof(_request.StandingCharges)); + } + + if (!seen.Add((scope.Scope, scope.ScopeId))) + { + throw new ArgumentException($"The standing-charge scope {scope.Scope} {scope.ScopeId} is given twice.", nameof(_request.StandingCharges)); + } + } + + return [.. scopes.Where(s => _book.HasAnyInScope(TariffComponent.BasePrice, s.Scope, s.ScopeId))]; + } + + private ManualCostResult BookManualCosts() + { + var cells = NewCells(); + var bookings = new List(); + var afterToday = new List(); + + foreach (var cost in _request.ManualCosts ?? []) + { + ArgumentNullException.ThrowIfNull(cost, nameof(_request.ManualCosts)); + if (!double.IsFinite(cost.Amount)) + { + throw new ArgumentException($"Manual cost {cost.Id} has no finite amount.", nameof(_request.ManualCosts)); + } + + var day = cost.PeriodStart; + if (BucketOf(day) is not { } bucket) + { + continue; + } + + if (day.DayNumber > _todayNumber) + { + afterToday.Add(cost.Id); + continue; + } + + var mismatch = CurrencyDiffers(cost.Currency); + cells[bucket].AddPriced(CostComponent.Manual, cost.Amount, BucketStatus.Available, mismatch); + bookings.Add(new ManualCostBooking(cost.Id, cost.CategoryId, cost.MeterId, day, bucket, cost.Amount, mismatch)); + } + + var built = cells.Select(c => c.Build()).ToList(); + return new ManualCostResult( + [.. bookings.OrderBy(b => b.Day).ThenBy(b => b.ManualCostId)], + [.. afterToday.Order()], + built, + CostAmount.Sum(built)); + } + + /// The price of a line in one month: the tariff in effect on the 15th, checked against the line's unit. + private LinePrice PriceOf(CostLine line, TariffComponent component, bool scopePriced, DateOnly month) + { + var date = TariffBook.PriceDate(month); + var tariff = line.Kind == BillLineKind.OwnPrice + ? _book.ResolveInScope(TariffComponent.UnitPrice, TariffScope.Meter, line.MeterId, date) + : _book.Resolve(component, line.MeterId, line.EnergyTypeId, date); + + if (tariff is null) + { + if (line.Kind == BillLineKind.OwnPrice && scopePriced) + { + return new LinePrice(Outcome.InParent, 0, null, TariffUnitIssue.None, TariffScope.Meter, line.MeterId); + } + + var (scope, scopeId) = SuggestedScope(line, component); + return new LinePrice(scopePriced ? Outcome.Gap : Outcome.NotPriced, 0, null, TariffUnitIssue.None, scope, scopeId); + } + + var fit = Fit(tariff, line.Unit); + if (!fit.Applies) + { + return new LinePrice(Outcome.Mismatch, 0, tariff, fit.Issue, tariff.ScopeType, ScopeIdOf(tariff)); + } + + return new LinePrice(Outcome.Priced, fit.Convert(tariff.Value), tariff, fit.NeedsWarning ? fit.Issue : TariffUnitIssue.None, tariff.ScopeType, ScopeIdOf(tariff)) + { + Unverified = fit.NeedsWarning, + }; + } + + /// + /// Where a missing price belongs: an own price on the meter; otherwise the most specific scope that already has + /// prices of the component for this line (so a new price continues its history), or the energy type when none has. + /// + private (TariffScope Scope, int? ScopeId) SuggestedScope(CostLine line, TariffComponent component) + { + if (line.Kind == BillLineKind.OwnPrice || _book.HasAnyInScope(component, TariffScope.Meter, line.MeterId)) + { + return (TariffScope.Meter, line.MeterId); + } + + if (!_book.HasAnyInScope(component, TariffScope.EnergyType, line.EnergyTypeId) && _book.HasAnyInScope(component, TariffScope.Global, null)) + { + return (TariffScope.Global, null); + } + + return (TariffScope.EnergyType, line.EnergyTypeId); + } + + private void AddQuantity( + CostAccumulator cell, + CostLine line, + TariffComponent component, + CostComponent target, + LinePrice price, + CostQuantity? quantity, + CostPart part) + { + var availability = quantity?.Availability ?? BucketStatus.Missing; + var known = quantity is { IsKnown: true }; + var amount = known ? quantity!.Amount!.Value : 0d; + + if (price.Outcome == Outcome.Priced) + { + cell.AddPriced(target, known ? amount * price.PerUnit : null, availability, price.Unverified); + if (price.Unverified) + { + Warn(price.Tariff!, price.Issue, line.MeterId, part.Month); + } + + return; + } + + // Zero is zero at any price: a known zero needs no price, so it reports nothing missing. + if (known && amount == 0) + { + cell.AddNothing(target, availability); + return; + } + + // No data at all: there is nothing to price, so no price is missing — the cost is unknown for want of a + // quantity, which the availability already says. Data that exists but cannot be cut (unresolved, pending, + // invalid) still needs its price. + if (!known && availability == BucketStatus.Missing) + { + cell.AddUnknown(availability); + return; + } + + var reason = price.Outcome switch + { + Outcome.NotPriced => CostStatus.NotPriced, + Outcome.Mismatch => CostStatus.UnitMismatch, + _ => CostStatus.PriceGap, + }; + var missing = new MissingPrice( + component, reason, price.Scope, price.ScopeId, line.MeterId, part.Month, part.Month, price.Tariff?.Id, price.Issue); + if (reason == CostStatus.NotPriced) + { + cell.AddNotPriced(missing); + } + else + { + cell.AddUnavailable(missing, availability); + } + } + + /// + /// D-40: the days of in service and not after today, each at the scope's base price of the + /// part's month spread over the days of its period. + /// + private void AddStandingCharge(CostAccumulator cell, TariffScope scope, int? scopeId, ServicePeriod? service, CostPart part, int? meterId) + { + var days = ServiceDays(part, service); + if (days == 0) + { + cell.AddNothing(CostComponent.Standing, availability: null); + return; + } + + var key = (scope, scope == TariffScope.Global ? null : scopeId, part.Month); + if (!_baseTariffs.TryGetValue(key, out var tariff)) + { + tariff = _book.ResolveInScope(TariffComponent.BasePrice, scope, scopeId, TariffBook.PriceDate(part.Month)); + _baseTariffs[key] = tariff; + } + + if (tariff is null) + { + cell.AddUnavailable( + new MissingPrice(TariffComponent.BasePrice, CostStatus.PriceGap, scope, key.Item2, meterId, part.Month, part.Month), + availability: null); + return; + } + + var accrual = Accrual(tariff); + if (!accrual.Applies) + { + cell.AddUnavailable( + new MissingPrice( + TariffComponent.BasePrice, CostStatus.UnitMismatch, tariff.ScopeType, ScopeIdOf(tariff), meterId, part.Month, part.Month, tariff.Id, accrual.Issue), + availability: null); + return; + } + + var unverified = accrual.Fit == TariffUnitFit.Unverified; + cell.AddPriced(CostComponent.Standing, days * accrual.PerDay(tariff.Value, part.FirstDay), BucketStatus.Available, unverified); + if (unverified) + { + Warn(tariff, accrual.Issue, meterId, part.Month); + } + } + + /// The bucket whose days contain , or null (buckets never overlap). + private int? BucketOf(DateOnly day) => + _parts.FirstOrDefault(p => p.FirstDay <= day && day < p.EndDay)?.BucketIndex; + + /// The days of a part inside the service period and not after today. + private int ServiceDays(CostPart part, ServicePeriod? service) + { + if (service is null) + { + return 0; + } + + var start = Math.Max(part.FirstDay.DayNumber, service.FirstDay.DayNumber); + var end = Math.Min(part.EndDay.DayNumber, _todayNumber + 1); + if (service.LastDay is { } last) + { + end = Math.Min(end, last.DayNumber + 1); + } + + return Math.Max(0, end - start); + } + + private Dictionary IndexQuantities(CostLine line) + { + var index = new Dictionary(); + foreach (var quantity in line.Quantities) + { + ArgumentNullException.ThrowIfNull(quantity, nameof(_request.Lines)); + if (!_partsByFirstDay.TryGetValue(quantity.FirstDay, out var part) || part.EndDay != quantity.EndDay) + { + throw new ArgumentException( + $"Meter {line.MeterId}: the quantity for {quantity.FirstDay:yyyy-MM-dd} – {quantity.EndDay:yyyy-MM-dd} is not a part of the request; " + + "read quantities per CostCalculator.Parts.", + nameof(_request.Lines)); + } + + if (quantity.Amount is { } amount && !double.IsFinite(amount)) + { + throw new ArgumentException( + $"Meter {line.MeterId}: the quantity for {quantity.FirstDay:yyyy-MM-dd} is not finite; report it as Invalid instead.", + nameof(_request.Lines)); + } + + if (!index.TryAdd(quantity.FirstDay, quantity)) + { + throw new ArgumentException( + $"Meter {line.MeterId}: the quantity for {quantity.FirstDay:yyyy-MM-dd} is given twice.", nameof(_request.Lines)); + } + } + + return index; + } + + private TariffApplicability Fit(Tariff tariff, string? unit) + { + var key = (tariff, unit ?? string.Empty); + if (!_fits.TryGetValue(key, out var fit)) + { + fit = _book.Applicability(tariff, unit); + _fits[key] = fit; + } + + return fit; + } + + private BasePriceAccrual Accrual(Tariff tariff) + { + if (!_accruals.TryGetValue(tariff, out var accrual)) + { + accrual = _book.Accrual(tariff); + _accruals[tariff] = accrual; + } + + return accrual; + } + + /// + /// True when a manual cost's currency is recognised and is not the instance's major currency (a minor unit such as + /// "ct" counts as different: the amount would be off by its scale). + /// + private bool CurrencyDiffers(string? currency) + { + var parsed = TariffUnit.Parse(currency); + return parsed.Currency is { } code && (parsed.CurrencyScale != 1 || !TariffUnit.IsQuotedIn(code, _book.Currency)); + } + + private void Warn(Tariff tariff, TariffUnitIssue issue, int? meterId, DateOnly month) + { + var key = new WarningKey(tariff.Id, tariff.Component, issue, meterId); + if (!_warnings.TryGetValue(key, out var first) || month < first) + { + _warnings[key] = month; + } + } + + private CostAccumulator[] NewCells() + { + var cells = new CostAccumulator[_request.Buckets.Count]; + for (var i = 0; i < cells.Length; i++) + { + cells[i] = new CostAccumulator(); + } + + return cells; + } + + private static int? ScopeIdOf(Tariff tariff) => tariff.ScopeType == TariffScope.Global ? null : tariff.ScopeId; + + /// A line's price in one month: what happened, the price per normalized unit, and where it came from. + private sealed record LinePrice(Outcome Outcome, double PerUnit, Tariff? Tariff, TariffUnitIssue Issue, TariffScope Scope, int? ScopeId) + { + public bool Unverified { get; init; } + } + + private readonly record struct WarningKey(int TariffId, TariffComponent Component, TariffUnitIssue Issue, int? MeterId); +} diff --git a/src/Core/Analysis/Costing/CostModels.cs b/src/Core/Analysis/Costing/CostModels.cs new file mode 100644 index 0000000..8068f08 --- /dev/null +++ b/src/Core/Analysis/Costing/CostModels.cs @@ -0,0 +1,388 @@ +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Costing; + +/// +/// How much of a cost figure could be priced (D-38). A separate dimension from the availability of the quantity +/// behind it (): a month can be fully measured and have no price, or be priced +/// and have no data. +/// +public enum CostStatus +{ + /// + /// Everything that needed a price had one. An explicit zero tariff is priced (a valid zero), and so is a figure + /// with nothing to price (a known zero quantity needs no price). A component whose scope has no tariff at all may + /// have been left out: see . + /// + Priced, + + /// + /// Some of the figure is priced and some is not (a price gap or a unit mismatch in some months). The value is the + /// priced part only, like a partial quantity total. + /// + Partial, + + /// + /// Nothing in the figure has any tariff at any date ("not priced (no tariff)"). An attention item, not an unknown: + /// it never makes a total partial (D-38). + /// + NotPriced, + + /// + /// The scope is priced at some date but not in the months this figure needs, and nothing else in it was priced: + /// the cost is unavailable for those months (D-38). + /// + PriceGap, + + /// + /// The tariff in effect does not fit the meter's normalized unit or the instance currency ("unavailable (unit)", + /// D-37), and nothing else in the figure was priced. + /// + UnitMismatch, +} + +/// +/// The piece of a bucket that lies inside one local month — the unit the calculator prices (D-36). A month bucket is +/// one part, a year bucket twelve, a week straddling a month boundary two, a day bucket one. Every part is priced +/// with the price of its own month, which is why the bucket size never changes a total. +/// +/// The index of the bucket in the request's bucket list. +/// The first local day of the part. +/// The local day after the part (exclusive); in the same month as or the 1st of the next. +public sealed record CostPart(int BucketIndex, DateOnly FirstDay, DateOnly EndDay) +{ + /// The local month the part lies in, as its 1st. + public DateOnly Month => new(FirstDay.Year, FirstDay.Month, 1); + + /// How many local days the part has. + public int Days => EndDay.DayNumber - FirstDay.DayNumber; +} + +/// +/// A line's quantity over one , in the line's normalized unit, with the reader's availability +/// for it. and must be exactly those of a part of the request +/// (); a part the line gives nothing for reads as . +/// +/// The part's first local day. +/// The part's end (exclusive). +/// +/// The net quantity to price: for a billed meter with separately billed subsections (D-35), its quantity minus +/// theirs, converted by . For a feed-in line, the export. Only read when +/// is or . +/// +/// The quantity's availability over the part (D-14), as the reader evaluated it. +public sealed record CostQuantity(DateOnly FirstDay, DateOnly EndDay, double? Amount, BucketStatus Availability) +{ + /// A measured quantity over . + public static CostQuantity Known(CostPart part, double amount, BucketStatus availability = BucketStatus.Available) + { + ArgumentNullException.ThrowIfNull(part); + + return new CostQuantity(part.FirstDay, part.EndDay, amount, availability); + } + + /// A quantity the reader could not give over . + public static CostQuantity Unknown(CostPart part, BucketStatus availability = BucketStatus.Missing) + { + ArgumentNullException.ThrowIfNull(part); + + return new CostQuantity(part.FirstDay, part.EndDay, null, availability); + } + + /// True when is a number the calculator may price. + public bool IsKnown => Amount is not null && Availability is BucketStatus.Available or BucketStatus.Partial; +} + +/// +/// The local days a standing-charge scope is in service (D-40): from the earliest or +/// first data to the latest , or open while still in service — regardless of reading +/// gaps. Both ends are inclusive local days. +/// +public sealed record ServicePeriod +{ + /// lies before . + public ServicePeriod(DateOnly firstDay, DateOnly? lastDay = null) + { + if (lastDay is { } last && last < firstDay) + { + throw new ArgumentException($"A service period cannot end ({last:yyyy-MM-dd}) before it starts ({firstDay:yyyy-MM-dd}).", nameof(lastDay)); + } + + FirstDay = firstDay; + LastDay = lastDay; + } + + /// The first local day in service. + public DateOnly FirstDay { get; } + + /// The last local day in service (inclusive); null while still in service (until today). + public DateOnly? LastDay { get; } + + public bool Contains(DateOnly day) => day >= FirstDay && (LastDay is null || day <= LastDay); + + /// + /// One meter's service period: from , or else its first data, to + /// (open when not retired). Null when neither start is known (a meter that never + /// delivered data and has no install date is not in service), or when the meter was retired before it started. + /// + /// An install date wins over earlier data: outside its service period a meter contributes a known zero (D-24). + public static ServicePeriod? ForMeter(DateOnly? installedAt, DateOnly? retiredAt, DateOnly? firstDataDay) + { + if ((installedAt ?? firstDataDay) is not { } first) + { + return null; + } + + return retiredAt is { } last && last < first ? null : new ServicePeriod(first, retiredAt); + } + + /// + /// A scope's service period over its meters' (D-40): the earliest start to the latest end, open when any meter is + /// still in service. Null when no meter is in service. + /// + public static ServicePeriod? Span(IEnumerable periods) + { + ArgumentNullException.ThrowIfNull(periods); + + var known = periods.OfType().ToList(); + if (known.Count == 0) + { + return null; + } + + var first = known.Min(p => p.FirstDay); + return known.Exists(p => p.LastDay is null) ? new ServicePeriod(first) : new ServicePeriod(first, known.Max(p => p.LastDay)); + } +} + +/// +/// One priceable line of a bill (D-34/D-35), as gives it, with the quantities the +/// reader measured for it. The calculator never looks at topology: deductions of separately billed subsections are +/// already taken out of . +/// +/// The meter the line prices. +/// Its energy type (tariff precedence meter, type, global). +/// +/// How it is priced: at the normal precedence, +/// at its own meter-scoped unit price only, as a credit on its export. +/// +/// The meter's normalized unit (D-20); unit and feed-in prices must fit it (D-37). +/// The net quantity per part of the request. +/// +/// The meter's own service period, over which a meter-scoped standing charge accrues on this line (D-40). Null when +/// the meter is not in service, which accrues nothing. +/// +public sealed record CostLine( + int MeterId, + int EnergyTypeId, + BillLineKind Kind, + string Unit, + IReadOnlyList Quantities, + ServicePeriod? Service = null) +{ + /// + /// Optional: the line's net quantity over whole buckets of several parts, for buckets where a part is + /// (A-16). A reading interval longer than a month (a tank dipped every few + /// months, a quarterly delta) cannot be cut into months, but a bucket holding it whole can be priced as a whole when + /// one price covers every month it has data in. At most one per bucket. + /// + public IReadOnlyList? Spans { get; init; } +} + +/// +/// A line's quantity over a whole request bucket (), with the reader's availability for the +/// bucket as a whole. +/// +/// The bucket, by its index in the request. +/// The net quantity over the bucket; read only when is available or partial. +/// The quantity's availability over the whole bucket (D-14). +public sealed record CostSpan(int BucketIndex, double? Amount, BucketStatus Availability) +{ + /// True when is a number the calculator may price. + public bool IsKnown => Amount is not null && Availability is BucketStatus.Available or BucketStatus.Partial; +} + +/// +/// A standing charge to accrue as its own row (D-40): a type- or global-scoped one, never split across meters, or a +/// meter-scoped one on a meter the request has no line for (a PV or house meter behind the billed grid meter, A-18). +/// A meter-scoped charge of a meter that has a line accrues on its instead. +/// +/// , or . +/// The energy type or meter id; null for the global scope. +/// The scope's service period (see ); null accrues nothing. +public sealed record StandingChargeScope(TariffScope Scope, int? ScopeId, ServicePeriod? Service) +{ + public static StandingChargeScope ForEnergyType(int energyTypeId, ServicePeriod? service) => + new(TariffScope.EnergyType, energyTypeId, service); + + public static StandingChargeScope Global(ServicePeriod? service) => new(TariffScope.Global, null, service); + + public static StandingChargeScope ForMeter(int meterId, ServicePeriod? service) => new(TariffScope.Meter, meterId, service); +} + +/// +/// Everything one cost calculation prices. Which lines, standing-charge scopes and manual costs belong together is +/// the caller's choice: a type's bill, a category's cover (D-42), a virtual meter's sources (D-39), the whole +/// portfolio. +/// +/// The buckets to report (D-05); they must not overlap. +/// +/// The local date of the captured "now". Standing charges accrue and manual costs are booked only up to and including +/// it (D-40, D-41); a day accrues in full once it has begun. +/// +/// The tariffs, parsed once, with the instance currency (D-43). +/// The bill lines to price. +/// +/// The type and global scopes whose standing charges belong to this figure; a scope without any base-price tariff +/// adds no row. Leave out for a figure that excludes scope-level charges (a virtual meter's source costs, D-39). +/// +/// The manual costs that belong to this figure; booked on their day. +public sealed record CostRequest( + IReadOnlyList Buckets, + DateOnly Today, + TariffBook Tariffs, + IReadOnlyList Lines, + IReadOnlyList? StandingCharges = null, + IReadOnlyList? ManualCosts = null); + +/// +/// A price a figure needed and did not get (D-38, D-53): what to price, where the price would go, and from when — +/// what the attention item says and the tariff deep link pre-fills (D-52). +/// +/// , or . +/// , or . +/// +/// Where the price belongs: for a gap, the most specific scope that already has prices of the component (so the new +/// price continues that history); with no prices at all, the energy type (the meter for an own price or a +/// meter-scoped standing charge). For a unit mismatch, the scope of the tariff that does not fit. +/// +/// The meter or energy type id; null for the global scope. +/// The bill line it affects; null for a type or global standing-charge row. +/// The first local month (its 1st) of the request that lacks the price. +/// The last such month. +/// For a unit mismatch, the tariff that does not fit. +/// For a unit mismatch, why (, , …). +public sealed record MissingPrice( + TariffComponent Component, + CostStatus Reason, + TariffScope Scope, + int? ScopeId, + int? MeterId, + DateOnly FirstMonth, + DateOnly LastMonth, + int? TariffId = null, + TariffUnitIssue Issue = TariffUnitIssue.None) +{ + /// + /// True for a missing feed-in price: an optional credit rather than a required price (brief §6.2). It is only ever + /// reported for a grid_export meter with export to credit. + /// + public bool IsCredit => Component == TariffComponent.FeedIn; +} + +/// +/// A tariff that was applied although its unit could not be checked (D-37: an unparseable unit applies with a +/// warning; an unreadable standing-charge unit is taken per month). +/// +/// The tariff. +/// Its component. +/// What could not be checked. +/// The line it priced; null for a type or global standing-charge row. +/// The first local month of the request it priced. +public sealed record TariffWarning(int TariffId, TariffComponent Component, TariffUnitIssue Issue, int? MeterId, DateOnly FirstMonth); + +/// A manual cost booked in full on its local day (D-41). +/// The manual cost. +/// Its category, if any. +/// Its meter, if any (the caller attributes it to that meter's categories, D-41/D-42). +/// The local day it is booked on. +/// The bucket that day falls in. +/// The amount, in full; is informational. +/// +/// True when its currency is a recognised currency other than the instance's. It is still booked — the CSV importer +/// stamps every manual cost "EUR" and no screen can change it — and the figure is flagged +/// . +/// +public sealed record ManualCostBooking( + int ManualCostId, + int? CategoryId, + int? MeterId, + DateOnly Day, + int BucketIndex, + double Amount, + bool CurrencyMismatch); + +/// A priced bill line: one per bucket and their total. +/// The line's meter. +/// Its energy type. +/// How it was priced. +/// The unit its quantity was priced in. +/// One figure per request bucket, in request order. +/// The figure over the whole request. +/// +/// For an line: the months of the request (their 1st) in which the meter had no +/// meter-scoped unit price of its own, so it was not billed separately — its quantity belongs to its parent's line +/// then (see ). Empty for other lines. +/// +public sealed record CostLineResult( + int MeterId, + int EnergyTypeId, + BillLineKind Kind, + string Unit, + IReadOnlyList Buckets, + CostAmount Total, + IReadOnlyList MonthsWithoutOwnPrice) +{ + /// + /// The months (their 1st) of a bucket given as a span whose quantity could not be + /// priced as a whole because the price changes inside it (A-16): the cost stays unavailable there, and this says + /// why. Empty otherwise. + /// + public IReadOnlyList MonthsWithPriceChangeInsideInterval { get; init; } = []; +} + +/// A type- or global-scoped standing-charge row ("Standing charge — <type>" / "— global", D-40). +public sealed record StandingChargeResult( + TariffScope Scope, + int? ScopeId, + IReadOnlyList Buckets, + CostAmount Total); + +/// The manual costs of a request (D-41). +/// Every manual cost booked, by day then id. +/// +/// Manual costs whose day lies inside the request but after today: not booked (D-41), listed so a page can say so +/// rather than silently drop them (compare D-04). +/// +/// The booked amounts per bucket. +/// The booked amount over the request. +public sealed record ManualCostResult( + IReadOnlyList Bookings, + IReadOnlyList AfterTodayIds, + IReadOnlyList Buckets, + CostAmount Total); + +/// The priced request: every line, standing-charge row and manual cost, per bucket and in total. +/// The instance currency every amount is in (D-43). +/// The request's buckets. +/// One result per request line, in request order. +/// One row per requested scope that has base-price tariffs, in request order. +/// The manual costs. +/// Lines, rows and manual costs together, per bucket. +/// Everything over the whole request. +/// Tariffs applied with an unchecked unit, each once with the first month it priced. +public sealed record CostResult( + string Currency, + IReadOnlyList Buckets, + IReadOnlyList Lines, + IReadOnlyList StandingCharges, + ManualCostResult ManualCosts, + IReadOnlyList Totals, + CostAmount Total, + IReadOnlyList Warnings) +{ + /// Every price the request needed and did not get (the same list as of ). + public IReadOnlyList MissingPrices => Total.MissingPrices; +} diff --git a/src/Core/Analysis/Costing/TariffBook.cs b/src/Core/Analysis/Costing/TariffBook.cs new file mode 100644 index 0000000..4dc35a2 --- /dev/null +++ b/src/Core/Analysis/Costing/TariffBook.cs @@ -0,0 +1,201 @@ +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Costing; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Costing; + +/// +/// The instance's tariffs, each unit parsed once (D-37), with the instance currency (D-43): what the cost calculator +/// resolves prices from. Immutable, so one book can serve every figure of a request. +/// +/// +/// +/// Resolution follows : a tariff is in effect from to +/// (both inclusive, open when null); the meter scope outranks the energy type, which +/// outranks global (global ignores ); within the winning scope the latest +/// wins, even over an older tariff whose still covers the +/// date. Where the resolver took whichever tie it met first, the book is deterministic: on the same scope and +/// the higher (the later entry) wins, then the earlier one in +/// the list. +/// +/// +/// A tariff whose lies before its can never be in effect +/// and is left out entirely — it does not make its scope "priced" either. +/// +/// +/// Every price is read on the 15th of its local month (D-36, ). +/// +/// +public sealed class TariffBook +{ + private readonly Dictionary _units; + private readonly Dictionary _byComponent; + + private TariffBook(IReadOnlyList tariffs, string currency) + { + Tariffs = tariffs; + Currency = currency; + _units = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var tariff in tariffs) + { + _units.TryAdd(tariff, TariffUnit.Parse(tariff.Unit)); + } + + _byComponent = tariffs.GroupBy(t => t.Component).ToDictionary(g => g.Key, g => g.ToArray()); + } + + /// The instance currency every price is checked against (D-43). + public string Currency { get; } + + /// The tariffs that can be in effect, in the order they were given. + public IReadOnlyList Tariffs { get; } + + /// Parses every tariff's unit once. + /// All tariffs of the instance. + /// The instance currency (MeterVault__Currency): "EUR", "€", "CHF", …. + public static TariffBook Create(IEnumerable tariffs, string currency) + { + ArgumentNullException.ThrowIfNull(tariffs); + ArgumentException.ThrowIfNullOrWhiteSpace(currency); + + var usable = new List(); + foreach (var tariff in tariffs) + { + ArgumentNullException.ThrowIfNull(tariff, nameof(tariffs)); + if (tariff.ValidTo is not { } validTo || validTo >= tariff.ValidFrom) + { + usable.Add(tariff); + } + } + + return new TariffBook(usable, currency.Trim()); + } + + /// The day a month is priced on (D-36): the 15th of the local month containing . + public static DateOnly PriceDate(DateOnly day) => new(day.Year, day.Month, 15); + + /// + /// The tariff of in effect for a meter on by the normal + /// precedence (meter, energy type, global), or null when none is. + /// + public Tariff? Resolve(TariffComponent component, int meterId, int energyTypeId, DateOnly date) + { + Tariff? best = null; + var bestRank = 0; + foreach (var tariff in Candidates(component)) + { + if (!InEffect(tariff, date)) + { + continue; + } + + var rank = Rank(tariff, meterId, energyTypeId); + if (rank > bestRank || (rank == bestRank && rank > 0 && Newer(tariff, best!))) + { + best = tariff; + bestRank = rank; + } + } + + return best; + } + + /// + /// The tariff of in effect on within exactly one scope (no + /// fallback to another), or null. Standing charges are resolved this way: each scope's is its own charge (D-40). + /// + /// The component. + /// The scope. + /// The meter or energy type id; ignored for . + /// The day. + public Tariff? ResolveInScope(TariffComponent component, TariffScope scope, int? scopeId, DateOnly date) + { + Tariff? best = null; + foreach (var tariff in Candidates(component)) + { + if (InScope(tariff, scope, scopeId) && InEffect(tariff, date) && (best is null || Newer(tariff, best))) + { + best = tariff; + } + } + + return best; + } + + /// + /// True when any tariff of reaches the meter at any date (meter, energy type or + /// global scope). Without one the meter is "not priced (no tariff)" for that component; with one, a month + /// without a price is a gap (D-38). + /// + public bool HasAny(TariffComponent component, int meterId, int energyTypeId) => + Candidates(component).Any(t => Rank(t, meterId, energyTypeId) > 0); + + /// True when any tariff of exists in exactly this scope, at any date. + public bool HasAnyInScope(TariffComponent component, TariffScope scope, int? scopeId) => + Candidates(component).Any(t => InScope(t, scope, scopeId)); + + /// + /// True when the meter has a meter-scoped unit price at any date — the hasMeterScopedUnitPrice question of + /// (D-35). The unit is not checked: a separately billed meter whose own + /// price does not fit reports "unavailable (unit)" rather than quietly moving back into its parent's bill. + /// + public bool HasMeterScopedUnitPrice(int meterId) => HasAnyInScope(TariffComponent.UnitPrice, TariffScope.Meter, meterId); + + /// + /// True when the meter has its own meter-scoped unit price in effect on the 15th of 's + /// month: the months a separately billed subsection is priced on its own (D-35). The caller subtracts its + /// quantity from the billed ancestor only in these months; in the others it is billed inside its parent, and the + /// calculator gives its line nothing for them. + /// + public bool HasOwnUnitPrice(int meterId, DateOnly month) => + ResolveInScope(TariffComponent.UnitPrice, TariffScope.Meter, meterId, PriceDate(month)) is not null; + + /// The tariff's unit, parsed once. + public ParsedTariffUnit UnitOf(Tariff tariff) + { + ArgumentNullException.ThrowIfNull(tariff); + + return _units.TryGetValue(tariff, out var unit) ? unit : TariffUnit.Parse(tariff.Unit); + } + + /// + /// Whether a unit or feed-in tariff prices a quantity in (D-37), checked against the + /// instance currency; its turns the value into currency per meter unit. + /// + public TariffApplicability Applicability(Tariff tariff, string? meterUnit) + { + ArgumentNullException.ThrowIfNull(tariff); + + return TariffUnit.Applicability(UnitOf(tariff), meterUnit, tariff.Component, Currency); + } + + /// How a base-price tariff accrues per local day (D-37, D-40), checked against the instance currency. + public BasePriceAccrual Accrual(Tariff tariff) + { + ArgumentNullException.ThrowIfNull(tariff); + + return TariffUnit.BaseAccrual(UnitOf(tariff), Currency); + } + + private static bool InEffect(Tariff tariff, DateOnly date) => + tariff.ValidFrom <= date && (tariff.ValidTo is not { } validTo || validTo >= date); + + /// The resolver's ranks: meter 3, energy type 2, global 1 (whatever its scope id), anything else 0. + private static int Rank(Tariff tariff, int meterId, int energyTypeId) => tariff.ScopeType switch + { + TariffScope.Meter when tariff.ScopeId == meterId => 3, + TariffScope.EnergyType when tariff.ScopeId == energyTypeId => 2, + TariffScope.Global => 1, + _ => 0, + }; + + private static bool InScope(Tariff tariff, TariffScope scope, int? scopeId) => + tariff.ScopeType == scope && (scope == TariffScope.Global || tariff.ScopeId == scopeId); + + /// Within one scope: the later start wins, then the higher id; a full tie keeps the one met first. + private static bool Newer(Tariff candidate, Tariff incumbent) => + candidate.ValidFrom != incumbent.ValidFrom ? candidate.ValidFrom > incumbent.ValidFrom : candidate.Id > incumbent.Id; + + private Tariff[] Candidates(TariffComponent component) => + _byComponent.TryGetValue(component, out var tariffs) ? tariffs : []; +} diff --git a/src/Core/Analysis/Coverage/AvailableRange.cs b/src/Core/Analysis/Coverage/AvailableRange.cs new file mode 100644 index 0000000..197f115 --- /dev/null +++ b/src/Core/Analysis/Coverage/AvailableRange.cs @@ -0,0 +1,70 @@ +namespace MeterVault.Core.Analysis.Coverage; + +/// +/// The stretch of time a scope has data for (D-19), capped at now: its outer bounds as instants and as inclusive +/// local dates, and the latest local month that holds any of it — "latest period with data". +/// +/// +/// The bounds are outer bounds: a scope with a hole in 2024 still reads 2022 – 2026. They answer "which dates can +/// be asked for" (the all preset, D-02) and "where is the latest data" (brief §4.3), not "is every day +/// covered" — bucket statuses answer that. +/// +/// The first covered instant. +/// The end of the last covered stretch (exclusive), at or before now. +/// The local day of . +/// The local day holding the last covered instant. +public sealed record AvailableRange(DateTimeOffset From, DateTimeOffset To, DateOnly FirstDay, DateOnly LastDay) +{ + /// The first day of the latest local month with data (D-19). + public DateOnly LatestMonth => new(LastDay.Year, LastDay.Month, 1); + + /// The range of [from, to) in ; null when it is empty. + public static AvailableRange? Of(DateTimeOffset from, DateTimeOffset to, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + + if (to <= from) + { + return null; + } + + return new AvailableRange( + from.ToUniversalTime(), + to.ToUniversalTime(), + CalendarEdges.LocalDate(from, zone), + CalendarEdges.LocalDate(to.AddTicks(-1), zone)); + } + + /// + /// What a meter's coverage runs make available as of : the covered time of its non-gap runs + /// after , so a row recorded after now claims nothing (D-04). Null without any. + /// + public static AvailableRange? OfRuns(IReadOnlyList runs, DateTimeOffset now, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(runs); + + var covered = CoverageRuns.Covered(CoverageRuns.CapAt(runs, now, zone)); + return covered is { First: { } first, Last: { } last } ? Of(first, last, zone) : null; + } + + /// The smallest range holding every given one (nulls ignored); null when none is given. + public static AvailableRange? Union(IEnumerable ranges, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(ranges); + + DateTimeOffset? from = null; + DateTimeOffset? to = null; + foreach (var range in ranges) + { + if (range is null) + { + continue; + } + + from = from is { } f && f <= range.From ? f : range.From; + to = to is { } t && t >= range.To ? t : range.To; + } + + return from is { } start && to is { } end ? Of(start, end, zone) : null; + } +} diff --git a/src/Core/Analysis/Coverage/BucketCoverage.cs b/src/Core/Analysis/Coverage/BucketCoverage.cs new file mode 100644 index 0000000..a0f7a1e --- /dev/null +++ b/src/Core/Analysis/Coverage/BucketCoverage.cs @@ -0,0 +1,51 @@ +namespace MeterVault.Core.Analysis.Coverage; + +/// +/// How well a bucket is covered (D-14): its status and the reason behind it, plus the facts a tooltip or a +/// coverage summary needs — how much of the bucket is covered, between which instants, at what resolution, +/// and which known holes touch it. +/// +/// +/// , , +/// or ; the evaluator never produces the other statuses, which belong to +/// the calculation and rebuild layers. +/// +/// Why the bucket is not plainly available; when it is. +/// Covered time inside the bucket (the union of its non-gap runs). +/// The bucket's own length, after clipping to the period. +/// The first covered instant inside the bucket, or null when nothing covers it. +/// The end of the last covered stretch inside the bucket, or null. +/// The coarsest class among the runs covering the bucket, or null when none do. +/// +/// True when a first reading with an unknown start is booked in this bucket (D-14) — as the caller said, from +/// the rollup's baseline-delta flag (A-01): its value contains an amount of unknown extent, so it is partial +/// and kept out of comparisons and projections. +/// +/// The reasons of the gap runs touching the bucket, most significant first, each once. +public sealed record BucketCoverage( + BucketStatus Status, + ValueIssue Issue, + TimeSpan Covered, + TimeSpan Length, + DateTimeOffset? FirstCovered, + DateTimeOffset? LastCovered, + ResolutionClass? Resolution, + bool OpeningBalance, + IReadOnlyList Gaps) +{ + /// + /// Covered time as a share of the bucket, 0 to 1. It says how much time is covered, not how complete the + /// value is: an unresolved bucket can be fully covered by data too coarse to divide. + /// + public double CoveredFraction => Length > TimeSpan.Zero ? Math.Min(1d, Covered / Length) : 0d; + + /// + /// The bucket's value under this coverage: the amount for an available or partial bucket (a partial one + /// keeps its partial total), none for a missing or unresolved one — a sum over a bucket nothing covers, + /// or over data too coarse to divide, is not a number to plot. + /// + public BucketValue ToValue(double amount, Provenance provenance) => + Status is BucketStatus.Available or BucketStatus.Partial + ? new BucketValue(amount, Status, provenance, Issue) + : new BucketValue(null, Status, provenance, Issue); +} diff --git a/src/Core/Analysis/Coverage/CalendarEdges.cs b/src/Core/Analysis/Coverage/CalendarEdges.cs new file mode 100644 index 0000000..f457fd6 --- /dev/null +++ b/src/Core/Analysis/Coverage/CalendarEdges.cs @@ -0,0 +1,84 @@ +using MeterVault.Core.Normalization; + +namespace MeterVault.Core.Analysis.Coverage; + +/// +/// Local calendar edges — midnights, Monday midnights, month starts — as UTC instants. Coverage checks +/// compare instants, but the edges they compare against are the instance zone's (SDD §10), so every edge +/// goes through , the one place that +/// knows how a day starts across DST gaps and contradictory zone data. +/// +internal static class CalendarEdges +{ + public static DateOnly LocalDate(DateTimeOffset instant, TimeZoneInfo zone) => + DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(instant, zone).DateTime); + + public static DateTimeOffset Midnight(DateOnly date, TimeZoneInfo zone) => GapAttribution.LocalMidnight(date, zone); + + public static bool IsMidnight(DateTimeOffset instant, TimeZoneInfo zone) => + Midnight(LocalDate(instant, zone), zone) == instant; + + public static bool IsMonthStart(DateTimeOffset instant, TimeZoneInfo zone) + { + var date = LocalDate(instant, zone); + return date.Day == 1 && Midnight(date, zone) == instant; + } + + /// The start of the unit containing , at or before it. + public static DateTimeOffset Floor(DateTimeOffset instant, CalendarUnit unit, TimeZoneInfo zone) + { + var start = Midnight(UnitStart(LocalDate(instant, zone), unit), zone); + + // Contradictory zone data can put a day's first instant a little after an instant ConvertTime files + // under that day; never hand back an edge after the instant being floored. + return start <= instant ? start : instant; + } + + /// The first unit edge at or after . + public static DateTimeOffset Ceiling(DateTimeOffset instant, CalendarUnit unit, TimeZoneInfo zone) + { + var floor = Floor(instant, unit, zone); + if (floor == instant) + { + return instant; + } + + var next = Midnight(Next(UnitStart(LocalDate(instant, zone), unit), unit), zone); + return next > instant ? next : instant; + } + + /// The local month start at or before . + public static DateTimeOffset MonthStart(DateTimeOffset instant, TimeZoneInfo zone) => Floor(instant, CalendarUnit.Month, zone); + + /// The local month start after the month containing . + public static DateTimeOffset NextMonthStart(DateTimeOffset instant, TimeZoneInfo zone) + { + var date = LocalDate(instant, zone); + var next = Midnight(new DateOnly(date.Year, date.Month, 1).AddMonths(1), zone); + return next > instant ? next : Midnight(new DateOnly(date.Year, date.Month, 1).AddMonths(2), zone); + } + + private static DateOnly UnitStart(DateOnly date, CalendarUnit unit) => unit switch + { + CalendarUnit.Day => date, + CalendarUnit.Week => date.AddDays(-(((int)date.DayOfWeek + 6) % 7)), + CalendarUnit.Month => new DateOnly(date.Year, date.Month, 1), + _ => throw new ArgumentOutOfRangeException(nameof(unit), unit, null), + }; + + private static DateOnly Next(DateOnly unitStart, CalendarUnit unit) => unit switch + { + CalendarUnit.Day => unitStart.AddDays(1), + CalendarUnit.Week => unitStart.AddDays(7), + CalendarUnit.Month => unitStart.AddMonths(1), + _ => throw new ArgumentOutOfRangeException(nameof(unit), unit, null), + }; +} + +/// A local calendar unit whose edges a coverage boundary can be snapped to. +internal enum CalendarUnit +{ + Day, + Week, + Month, +} diff --git a/src/Core/Analysis/Coverage/CoverageBuilder.cs b/src/Core/Analysis/Coverage/CoverageBuilder.cs new file mode 100644 index 0000000..a60ada4 --- /dev/null +++ b/src/Core/Analysis/Coverage/CoverageBuilder.cs @@ -0,0 +1,130 @@ +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; + +namespace MeterVault.Core.Analysis.Coverage; + +/// +/// Turns one meter's normalized rows into the runs of time its data covers (D-13), from the source +/// intervals the engine attached to them (, +/// , ). +/// +/// +/// +/// Sums cannot say what they are made of: twelve monthly sheet rows and one reading after a twelve-year +/// silence can add up to the same number. Coverage records what a bucket may honestly claim — which stretch +/// of time has data, how finely it is resolved, and where the data is a known hole. So a run is a stretch +/// of consecutive, touching row intervals of one , and time no interval covers +/// is simply not in any run. +/// +/// +/// A row is classified by the whole interval between the two readings it came from, not by its own +/// segment: a divided share is estimated from that interval, so it is no finer than it. It is no coarser +/// than either (A-03): GapAttribution cut it at every local month +/// start, so month and year buckets can place it exactly. The shares of one interval are told from those of +/// the next by that source interval — both kinds of neighbour meet at a month start. +/// +/// +/// says that no interval of the run straddles a local month +/// boundary undivided (A-02): each was divided at the boundaries it crossed, stood still across them (a zero +/// is zero in every month), or lies within one local month — a monthly sheet row covers exactly its month. +/// An undivided interval that does straddle a month boundary is always its own run, like an interval longer +/// than a month (D-13): its two ends are the only places its amount can be cut, and a run keeps only its own +/// ends, so merging it with its neighbours would lose them. +/// +/// +/// A row whose interval is a known hole — an unexplained decrease, a reset without the old register's +/// final value, a sample gap — becomes a gap run with its reason, never coverage. A first reading with an +/// unknown start () has a zero-length interval and adds no run +/// (A-01): the rollup day it is booked in carries the flag. Where rows overlap (a coalesced row, a month row +/// next to live increments) the earlier-starting interval keeps the overlap, so runs never overlap. +/// +/// +/// Runs are built uncapped, to be stored as they are (A-04): each keeps where its last row interval starts +/// (), and a reader cuts coverage at its own "now" with +/// . Capping here would freeze the recompute's instant into stored coverage. +/// +/// +public static class CoverageBuilder +{ + /// + /// The coverage runs of one meter's rows, oldest first, non-overlapping, uncapped, in UTC. Each run + /// starts at a different instant, which fits the (meter_id, span_from) key. Rows without an + /// interval (read back from the database, or virtual) and zero-length intervals are ignored. + /// + /// One meter's normalized rows, as the engine returned them. + /// The instance timezone the rows were normalized in; months are its local months. + public static IReadOnlyList Build(IEnumerable rows, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(rows); + ArgumentNullException.ThrowIfNull(zone); + + var pieces = rows + .Where(r => r.IntervalStart is { } start && r.IntervalEnd is { } end && end > start) + .Select(r => PieceOf(r, zone)) + .OrderBy(p => p.From) + .ThenBy(p => p.To) + .ToList(); + + var runs = new List(); + foreach (var piece in pieces) + { + var from = piece.From; + if (runs.Count > 0 && from < runs[^1].To) + { + from = runs[^1].To; + if (from >= piece.To) + { + continue; + } + } + + if (runs.Count > 0 && Extends(runs[^1], from, piece)) + { + runs[^1] = runs[^1] with { To = piece.To, LastIntervalStart = from }; + } + else + { + runs.Add(new CoverageRun(from, piece.To, piece.Resolution, piece.MonthAligned, piece.Gap, LastIntervalStart: from)); + } + } + + return runs; + } + + private static Piece PieceOf(Consumption row, TimeZoneInfo zone) + { + var from = row.IntervalStart!.Value.ToUniversalTime(); + var to = row.IntervalEnd!.Value.ToUniversalTime(); + var sourceFrom = (row.SourceStart ?? from).ToUniversalTime(); + var sourceTo = (row.SourceEnd ?? to).ToUniversalTime(); + + // Resolution is a property of the data, not of how much of it a share or an overlap leaves to show. + var resolution = ResolutionClassifier.Classify(sourceFrom, sourceTo); + if (row.Divided && resolution > ResolutionClass.Month) + { + resolution = ResolutionClass.Month; + } + + var monthAligned = row.Gap == CoverageGapReason.None + && (row.Divided || !GapAttribution.CrossesLocalMonthBoundary(from, to, zone)); + + return new Piece(from, to, resolution, monthAligned, row.Gap); + } + + /// + /// Whether an interval continues the last run: it touches it, and has the same class, the same month + /// alignment and the same gap reason. An interval longer than a month never joins another, and neither + /// does an undivided one that straddles a month boundary; consecutive holes of one reason do join. + /// + private static bool Extends(CoverageRun run, DateTimeOffset from, Piece piece) => + run.To == from + && piece.Resolution != ResolutionClass.Coarse + && run.Resolution == piece.Resolution + && run.DividedAtMonths == piece.MonthAligned + && run.Gap == piece.Gap + && (piece.MonthAligned || piece.Gap != CoverageGapReason.None); + + /// A row's interval, with the class of the source interval it came from. + private readonly record struct Piece( + DateTimeOffset From, DateTimeOffset To, ResolutionClass Resolution, bool MonthAligned, CoverageGapReason Gap); +} diff --git a/src/Core/Analysis/Coverage/CoverageEvaluator.cs b/src/Core/Analysis/Coverage/CoverageEvaluator.cs new file mode 100644 index 0000000..b01ee0d --- /dev/null +++ b/src/Core/Analysis/Coverage/CoverageEvaluator.cs @@ -0,0 +1,490 @@ +namespace MeterVault.Core.Analysis.Coverage; + +/// +/// Decides a bucket's status from the coverage runs of a meter (D-14): available, partial, missing or +/// unresolved. +/// +/// +/// +/// The exact rule, for a bucket [From, To) and the runs that overlap it by a positive length: +/// +/// +/// +/// Missing when no non-gap run overlaps the bucket. The issue is the reason of a gap run overlapping +/// it ( for an unexplained decrease or a reset without a previous +/// value, ), otherwise . Exception: a +/// bucket holding an opening balance is partial, never missing, because a value is booked in it. +/// +/// +/// Unresolved () when any overlapping non-gap run fails the +/// resolution test below. It wins over partial: a value too coarse to divide is not a partial total. +/// +/// +/// Partial when an opening balance is booked in the bucket (), +/// or when the union of the overlapping non-gap runs, clipped to the bucket, covers less than the bucket minus +/// (one minute of poll jitter) — the issue is then the reason of a gap run +/// overlapping the bucket, else . A bucket that ends at now is up to +/// date, not partial, when its coverage runs without a hole to within one interval of the covering run's class +/// of its end (A-04): what accrued since the last reading is not yet known, and that is no shortfall. +/// +/// Available otherwise. An available bucket with no rows is a true zero. +/// +/// +/// Resolution test. A run resolves a bucket when any of these holds: +/// +/// +/// +/// its class is no coarser than and the bucket is a day +/// or a week: day buckets take hour or day runs, week buckets up to week runs. Month and year buckets take +/// hour or day runs on the class rule alone — such an interval crosses a month edge by at most 25 hours, +/// inside the tolerance of the shortest month — but a week- or month-class run only by one of the next two +/// tests: a user reads a dipstick or burner hours on any day, and an undivided month-long interval read +/// mid-month books most of one month in the next; +/// +/// +/// it is and the bucket is a month or a year — no interval of the +/// run straddles a local month edge undivided (A-02), so every amount lies in the month it is booked in; +/// +/// +/// the D-14 edge tolerance: no interval of the run crosses a bucket edge by more than +/// of the bucket's length. An undivided interval books its whole amount where +/// it ends, so the misbooked part is the stretch before the bucket when the run ends inside it, or the +/// stretch inside the bucket when the run ends after it. A month-aligned run is checked month part by month +/// part, each part booked in its own month — the exception D-14 makes for edges the normalizer divided at. +/// +/// +/// +/// The tolerance reads the run's bounds as interval bounds, which they are at a run's edges and throughout a +/// run that is one interval (an interval longer than a month, or an undivided one straddling a month edge, +/// D-13). For a run of several intervals it is conservative: an interval can only cross the bucket edge by +/// less than the run does. Every run that is not is a single +/// interval, so for month and year buckets the test is exact: a 45-day tank interval between 14 October and +/// 28 November leaves both months unresolved but the year resolved, because no part of it crosses a year +/// edge; dipsticks on 15 January and 14 February leave both months unresolved, and dipsticks on month-end +/// days keep them resolved. For day and week buckets the class rule is an approximation: a run of daily +/// readings taken at 06:00 resolves day buckets, although each interval straddles midnight. +/// +/// +/// Gap runs are known holes: they never count as covered time and never make a bucket unresolved. An opening +/// balance is not a run (A-01): whether one is booked in the bucket is an input — the baseline-delta flag of +/// the rollup days the bucket spans — because only the booked row says where it went. Zero-length runs claim +/// no time and are ignored. +/// +/// +/// Runs may be passed as stored: given now, every evaluation caps them with +/// first, so a row recorded after now is never counted as coverage (D-04). +/// +/// +public static class CoverageEvaluator +{ + /// How much coverage a bucket may lack and still count as fully covered (D-14). + public static readonly TimeSpan CoverageTolerance = TimeSpan.FromMinutes(1); + + /// The share of a bucket's length an undivided interval may cross its edge by (D-14). + public const double EdgeTolerance = 0.05; + + /// Evaluates one bucket of a physical meter against that meter's runs. + /// The bucket, already clipped to the period; its size must be concrete (not auto). + /// The meter's coverage runs, stored or already capped; any order. + /// The instance zone: month-aligned runs are split at its local month edges. + /// + /// True when a first reading with an unknown start is booked in the bucket (A-01): the baseline-delta flag + /// of any rollup day the bucket spans. + /// + /// + /// The captured now of the period, when the bucket may reach it: runs are capped there, and a bucket ending + /// exactly at it gets the up-to-date tolerance (A-04). Null evaluates the runs as given. + /// + public static BucketCoverage Evaluate( + AnalysisBucket bucket, IReadOnlyList runs, TimeZoneInfo zone, bool openingBalanceInBucket, DateTimeOffset? now = null) + { + ArgumentNullException.ThrowIfNull(bucket); + ArgumentNullException.ThrowIfNull(runs); + ArgumentNullException.ThrowIfNull(zone); + CheckBucket(bucket, nameof(bucket)); + + var usable = Usable(runs, now, zone); + var covering = usable.Where(r => !r.IsGap && Overlaps(r, bucket)).ToList(); + var gaps = usable.Where(r => r.IsGap && Overlaps(r, bucket)).ToList(); + return EvaluateCore(bucket, covering, gaps, zone, checkResolution: true, openingBalanceInBucket, now); + } + + /// + /// Evaluates every bucket of a series against one meter's runs in a single pass over them (m2 #8): the + /// runs are sorted once and each bucket only looks at those overlapping it, so a long series over a meter + /// with thousands of runs costs the runs plus the buckets, not their product. + /// + /// The series' buckets, each already clipped to the period; any order, results follow it. + /// The meter's coverage runs, stored or already capped; any order. + /// The instance zone. + /// + /// One flag per bucket, in the same order (A-01); null when no bucket holds an opening balance. + /// + /// As for . + /// A bucket ends before it starts, or the flags do not match the buckets. + public static IReadOnlyList EvaluateSeries( + IReadOnlyList buckets, + IReadOnlyList runs, + TimeZoneInfo zone, + IReadOnlyList? openingBalanceInBucket, + DateTimeOffset? now = null) + { + ArgumentNullException.ThrowIfNull(buckets); + ArgumentNullException.ThrowIfNull(runs); + ArgumentNullException.ThrowIfNull(zone); + var order = SeriesOrder(buckets, openingBalanceInBucket); + + var sweep = new Sweep(Usable(runs, now, zone)); + var results = new BucketCoverage[buckets.Count]; + foreach (var i in order) + { + var (covering, gaps) = sweep.Overlapping(buckets[i]); + results[i] = EvaluateCore(buckets[i], covering, gaps, zone, checkResolution: true, openingBalanceInBucket?[i] ?? false, now); + } + + return results; + } + + /// + /// Evaluates one bucket of a value combined from several sources — a virtual meter (D-27). + /// + /// + /// Strict: a source that is missing in the bucket makes it missing (), + /// and a source that is unresolved in it makes it unresolved. Each source's resolution is tested against + /// its own runs, whose bounds are real interval bounds. Coverage and gap reasons come from + /// : the bucket is partial when the time all sources cover together is + /// shorter than the bucket, and missing when they cover none of it jointly. To name the source behind a + /// status, evaluate each source with . + /// + /// The bucket, already clipped to the period. + /// Each source's coverage runs, stored or already capped. + /// The instance zone. + /// True when any source's opening balance is booked in the bucket (A-01). + /// As for ; every source is capped at it. + public static BucketCoverage EvaluateJoint( + AnalysisBucket bucket, + IReadOnlyList> perSource, + TimeZoneInfo zone, + bool openingBalanceInBucket, + DateTimeOffset? now = null) + { + ArgumentNullException.ThrowIfNull(bucket); + ArgumentNullException.ThrowIfNull(perSource); + ArgumentNullException.ThrowIfNull(zone); + CheckBucket(bucket, nameof(bucket)); + + var sources = perSource.Select(runs => Usable(runs, now, zone)).ToList(); + var joint = Usable(CoverageRuns.Intersect(sources), now: null, zone); + + var jointCoverage = EvaluateCore( + bucket, + joint.Where(r => !r.IsGap && Overlaps(r, bucket)).ToList(), + joint.Where(r => r.IsGap && Overlaps(r, bucket)).ToList(), + zone, + checkResolution: false, + openingBalanceInBucket, + now); + var sourceCoverage = sources + .Select(runs => EvaluateCore( + bucket, + runs.Where(r => !r.IsGap && Overlaps(r, bucket)).ToList(), + runs.Where(r => r.IsGap && Overlaps(r, bucket)).ToList(), + zone, + checkResolution: true, + openingBalance: false, + now)) + .ToList(); + + return Combine(jointCoverage, sourceCoverage); + } + + /// + /// for every bucket of a series, in one pass over each source's runs and over + /// their joint coverage. + /// + /// The series' buckets; any order, results follow it. + /// Each source's coverage runs, stored or already capped. + /// The instance zone. + /// One flag per bucket (any source's opening balance), or null for none. + /// As for . + public static IReadOnlyList EvaluateJointSeries( + IReadOnlyList buckets, + IReadOnlyList> perSource, + TimeZoneInfo zone, + IReadOnlyList? openingBalanceInBucket, + DateTimeOffset? now = null) + { + ArgumentNullException.ThrowIfNull(buckets); + ArgumentNullException.ThrowIfNull(perSource); + ArgumentNullException.ThrowIfNull(zone); + var order = SeriesOrder(buckets, openingBalanceInBucket); + + var sources = perSource.Select(runs => Usable(runs, now, zone)).ToList(); + var jointSweep = new Sweep(Usable(CoverageRuns.Intersect(sources), now: null, zone)); + var sourceSweeps = sources.Select(runs => new Sweep(runs)).ToList(); + + var results = new BucketCoverage[buckets.Count]; + foreach (var i in order) + { + var bucket = buckets[i]; + var (jointCovering, jointGaps) = jointSweep.Overlapping(bucket); + var joint = EvaluateCore(bucket, jointCovering, jointGaps, zone, checkResolution: false, openingBalanceInBucket?[i] ?? false, now); + var sourceCoverage = sourceSweeps + .Select(sweep => sweep.Overlapping(bucket)) + .Select(o => EvaluateCore(bucket, o.Covering, o.Gaps, zone, checkResolution: true, openingBalance: false, now)) + .ToList(); + results[i] = Combine(joint, sourceCoverage); + } + + return results; + } + + /// + /// Whether resolves (the resolution test in the type + /// remarks). A run that does not overlap the bucket trivially does. + /// + public static bool Resolves(CoverageRun run, AnalysisBucket bucket, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(run); + ArgumentNullException.ThrowIfNull(bucket); + ArgumentNullException.ThrowIfNull(zone); + + // The class rule alone settles day and week buckets, and month and year buckets for day-or-finer data + // (an interval of at most 25 hours crosses a month edge by less than the tolerance). An undivided week- + // or month-class interval across a month edge is its own run (CoverageBuilder), so the edge tolerance + // below judges it exactly (D-14). + if (run.Resolution <= ResolutionClassifier.CoarsestResolving(bucket.Size) + && (bucket.Size is BucketSize.Day or BucketSize.Week || run.Resolution <= ResolutionClass.Day)) + { + return true; + } + + if (run.DividedAtMonths && bucket.Size is BucketSize.Month or BucketSize.Year) + { + return true; + } + + var tolerance = (bucket.To - bucket.From) * EdgeTolerance; + if (!run.DividedAtMonths) + { + return WithinEdgeTolerance(run.From, run.To, bucket, tolerance); + } + + // Month-aligned: every local month part of the run is booked inside its month, so each part is its + // own interval as far as this bucket is concerned. Day and week buckets touch at most two such parts. + var end = run.To < bucket.To ? run.To : bucket.To; + var partStart = run.From > bucket.From ? run.From : CalendarEdges.MonthStart(bucket.From, zone); + if (partStart < run.From) + { + partStart = run.From; + } + + while (partStart < end) + { + var monthEnd = CalendarEdges.NextMonthStart(partStart, zone); + var partEnd = monthEnd < run.To ? monthEnd : run.To; + if (!WithinEdgeTolerance(partStart, partEnd, bucket, tolerance)) + { + return false; + } + + partStart = partEnd; + } + + return true; + } + + private static BucketCoverage EvaluateCore( + AnalysisBucket bucket, + List covering, + List gaps, + TimeZoneInfo zone, + bool checkResolution, + bool openingBalance, + DateTimeOffset? now) + { + var length = bucket.To - bucket.From; + if (length == TimeSpan.Zero) + { + // A bucket clipped to nothing (a to-date period resolved exactly at its start) holds no time and no row. + return new BucketCoverage(BucketStatus.Missing, ValueIssue.NoCoverage, TimeSpan.Zero, TimeSpan.Zero, null, null, null, false, []); + } + + var stretches = CoverageRuns.Union(covering, bucket.From, bucket.To); + var covered = stretches.Aggregate(TimeSpan.Zero, (sum, s) => sum + s.Length); + var reasons = gaps.Select(g => g.Gap).Distinct().OrderBy(Significance).ToList(); + + ResolutionClass? resolution = covering.Count > 0 ? covering.Max(r => r.Resolution) : null; + var unresolved = checkResolution && covering.Exists(r => !Resolves(r, bucket, zone)); + var complete = covered >= length - CoverageTolerance || UpToDate(bucket, covering, gaps, stretches, covered, now); + + var (status, issue) = (covering.Count, unresolved, openingBalance) switch + { + (0, _, true) => (BucketStatus.Partial, ValueIssue.OpeningBalance), + (0, _, false) => (BucketStatus.Missing, reasons.Count > 0 ? IssueOf(reasons[0]) : ValueIssue.NoCoverage), + (_, true, _) => (BucketStatus.Unresolved, ValueIssue.CoarseResolution), + (_, false, true) => (BucketStatus.Partial, ValueIssue.OpeningBalance), + _ when !complete => (BucketStatus.Partial, reasons.Count > 0 ? IssueOf(reasons[0]) : ValueIssue.PartialCoverage), + _ => (BucketStatus.Available, ValueIssue.None), + }; + + return new BucketCoverage( + status, + issue, + covered, + length, + stretches.Count > 0 ? stretches[0].From : null, + stretches.Count > 0 ? stretches[^1].To : null, + resolution, + openingBalance, + reasons); + } + + /// + /// True when a bucket that ends at now is covered without a hole up to its last covered instant, and that + /// instant lies within one interval of the covering run's class of now (A-04): the next reading has simply + /// not happened yet. A known hole in the tail (a gap run) is a shortfall, not lag. + /// + private static bool UpToDate( + AnalysisBucket bucket, List covering, List gaps, List stretches, TimeSpan covered, DateTimeOffset? now) + { + if (now is not { } cut || bucket.To != cut || stretches.Count == 0) + { + return false; + } + + var lastCovered = stretches[^1].To; + var tail = bucket.To - lastCovered; + if (covered + tail < bucket.To - bucket.From - CoverageTolerance || gaps.Exists(g => g.To > lastCovered)) + { + return false; + } + + var reaching = covering.Where(r => r.To == lastCovered).Select(r => r.Resolution).DefaultIfEmpty(ResolutionClass.Hour).Max(); + return tail <= ResolutionClassifier.LimitOf(reaching); + } + + /// The strict D-27 combination of a joint evaluation with each source's own. + private static BucketCoverage Combine(BucketCoverage joint, List sources) + { + if (sources.Exists(s => s.Status == BucketStatus.Missing)) + { + return joint with { Status = BucketStatus.Missing, Issue = ValueIssue.MissingSource }; + } + + if (sources.Exists(s => s.Status == BucketStatus.Unresolved)) + { + var coarsest = sources.Where(s => s.Resolution is not null).Max(s => s.Resolution); + return joint with { Status = BucketStatus.Unresolved, Issue = ValueIssue.CoarseResolution, Resolution = coarsest }; + } + + return joint; + } + + /// + /// True when an undivided interval [from, to), booked where it ends, misbooks at most + /// of time for this bucket. + /// + private static bool WithinEdgeTolerance(DateTimeOffset from, DateTimeOffset to, AnalysisBucket bucket, TimeSpan tolerance) + { + if (to <= bucket.From || from >= bucket.To) + { + return true; + } + + if (to <= bucket.To) + { + // Booked in this bucket: whatever accrued before the bucket started is counted here too. + return bucket.From - from <= tolerance; + } + + // Booked after this bucket: whatever accrued inside it is counted in a later bucket. + var overlapStart = from > bucket.From ? from : bucket.From; + return bucket.To - overlapStart <= tolerance; + } + + /// The runs as of now (capped, A-04), or as given; zero-length runs claim no time either way. + private static List Usable(IReadOnlyList runs, DateTimeOffset? now, TimeZoneInfo zone) => + now is { } cut ? [.. CoverageRuns.CapAt(runs, cut, zone)] : runs.Where(r => r.To > r.From).ToList(); + + private static bool Overlaps(CoverageRun run, AnalysisBucket bucket) => run.From < bucket.To && run.To > bucket.From; + + private static void CheckBucket(AnalysisBucket bucket, string parameter) + { + if (bucket.To < bucket.From) + { + throw new ArgumentException($"Bucket ends before it starts ({bucket.From:O} > {bucket.To:O}).", parameter); + } + } + + /// The buckets' indexes by start, after checking them and their flags. + private static int[] SeriesOrder(IReadOnlyList buckets, IReadOnlyList? openingBalanceInBucket) + { + if (openingBalanceInBucket is not null && openingBalanceInBucket.Count != buckets.Count) + { + throw new ArgumentException( + $"One opening-balance flag per bucket is needed ({openingBalanceInBucket.Count} for {buckets.Count}).", + nameof(openingBalanceInBucket)); + } + + foreach (var bucket in buckets) + { + ArgumentNullException.ThrowIfNull(bucket, nameof(buckets)); + CheckBucket(bucket, nameof(buckets)); + } + + return [.. Enumerable.Range(0, buckets.Count).OrderBy(i => buckets[i].From).ThenBy(i => buckets[i].To)]; + } + + private static int Significance(CoverageGapReason reason) => reason switch + { + CoverageGapReason.UnexplainedDecrease => 1, + CoverageGapReason.ResetWithoutPrevious => 2, + CoverageGapReason.SampleGap => 3, + _ => 4, + }; + + private static ValueIssue IssueOf(CoverageGapReason reason) => reason switch + { + CoverageGapReason.UnexplainedDecrease or CoverageGapReason.ResetWithoutPrevious => ValueIssue.RegisterDiscontinuity, + CoverageGapReason.SampleGap => ValueIssue.SampleGap, + _ => ValueIssue.NoCoverage, + }; + + /// + /// Walks runs sorted by start alongside buckets taken in order of their start, keeping only the runs that + /// can still overlap a later bucket. + /// + private sealed class Sweep(List runs) + { + private readonly List _runs = [.. runs.OrderBy(r => r.From).ThenBy(r => r.To)]; + + private readonly List _active = []; + + private int _next; + + /// The covering and gap runs overlapping ; buckets must come by ascending start. + public (List Covering, List Gaps) Overlapping(AnalysisBucket bucket) + { + while (_next < _runs.Count && _runs[_next].From < bucket.To) + { + _active.Add(_runs[_next++]); + } + + // No later bucket starts before this one, so a run ending by its start is done for good. + _active.RemoveAll(r => r.To <= bucket.From); + + var covering = new List(); + var gaps = new List(); + foreach (var run in _active) + { + if (run.From < bucket.To) + { + (run.IsGap ? gaps : covering).Add(run); + } + } + + return (covering, gaps); + } + } +} diff --git a/src/Core/Analysis/Coverage/CoverageRuns.cs b/src/Core/Analysis/Coverage/CoverageRuns.cs new file mode 100644 index 0000000..4eebe34 --- /dev/null +++ b/src/Core/Analysis/Coverage/CoverageRuns.cs @@ -0,0 +1,280 @@ +namespace MeterVault.Core.Analysis.Coverage; + +/// +/// Set operations over a meter's coverage runs (D-13): how much time they cover, where coverage stops at +/// "now", and the joint coverage of several sources that a virtual meter rests on (D-27). +/// +/// +/// Runs are what builds and the rebuild stores in meter_coverage, +/// uncapped. Everything here treats a run as the half-open range [From, To) and a gap run +/// () as a known hole — never as covered time. Zero-length runs claim no time. +/// Inputs need not be sorted or disjoint; overlapping runs are unioned rather than double-counted. +/// +public static class CoverageRuns +{ + /// The covered time of all non-gap runs, with the first and last covered instants. + public static CoveredSpan Covered(IReadOnlyList runs) + { + ArgumentNullException.ThrowIfNull(runs); + + return Summarize(Union(runs)); + } + + /// The covered time of all non-gap runs inside [from, to). + public static CoveredSpan Covered(IReadOnlyList runs, DateTimeOffset from, DateTimeOffset to) + { + ArgumentNullException.ThrowIfNull(runs); + + return Summarize(Union(runs, from, to)); + } + + /// + /// Coverage as of (D-04, A-04, A-14) — the one capping rule. A run that starts at or after + /// now is dropped. A run that reaches past now ends at now, except when now falls inside one of the run's + /// intervals whose row closes after now: then it ends where that interval can start at the latest — at the final + /// interval's start () when now falls inside that one, and otherwise at + /// the earliest instant the interval containing now can start (see remarks). + /// + /// + /// + /// A current-month label row covers the whole month and closes after now; clipping its run at now would + /// claim 1–19 September as covered by a row the reader leaves out, and an empty September would pass for + /// a true zero. Ending at the final interval's start gives up exactly that interval and nothing else — no + /// rounding down to calendar edges, which would throw away rows that were recorded before now. + /// + /// + /// When now falls inside an earlier interval (two or more rows closing after now — several readings stamped + /// ahead, a reading stamped more than a month ahead, whose month shares are several intervals, or a sheet row + /// carrying the current month's register into a later month), only the final interval's start is stored, so the + /// start of the interval containing now is not known. Such a run is always + /// (an undivided interval across a month edge is its own run, ), so that interval lies + /// inside now's local month and starts at or after its first instant; and it is no longer than its class admits + /// (). The run therefore ends at the later of the two bounds — the local + /// month start for month-class data (a label run gives up exactly the current month), now minus one interval for + /// finer data — and never claims time whose row is recorded after now. A coarse run ends at its start. + /// + /// + /// A capped run no longer knows where its final interval starts, so its + /// is cleared. Gap runs are simply cut at now. Zero-length runs + /// claim no time and are dropped. Capping twice at the same instant changes nothing. + /// + /// + /// The runs as stored, or already capped at the same instant. + /// The captured now of the reader. + /// The instance zone: the local month containing now bounds the interval containing it. + public static IReadOnlyList CapAt(IReadOnlyList runs, DateTimeOffset now, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(runs); + ArgumentNullException.ThrowIfNull(zone); + + var capped = new List(runs.Count); + foreach (var run in runs) + { + if (run.To <= run.From || run.From >= now) + { + continue; + } + + if (run.To <= now) + { + capped.Add(run); + continue; + } + + var end = run.IsGap || run.LastIntervalStart is not { } last + ? now + : now > last ? last : EarliestStartOfIntervalAt(run, now, zone); + if (end > run.From) + { + capped.Add(run with { To = end, LastIntervalStart = null }); + } + } + + return capped; + } + + /// + /// The earliest instant the run's interval containing can start, when that interval is not + /// the run's last one (): not before now's local month, not more than one interval of the run's + /// class before now, and not before the run. + /// + private static DateTimeOffset EarliestStartOfIntervalAt(CoverageRun run, DateTimeOffset now, TimeZoneInfo zone) + { + if (run.Resolution == ResolutionClass.Coarse) + { + return run.From; + } + + var monthStart = CalendarEdges.MonthStart(now, zone); + var oneInterval = now - ResolutionClassifier.LimitOf(run.Resolution); + var bound = monthStart > oneInterval ? monthStart : oneInterval; + return bound > run.From ? bound : run.From; + } + + /// + /// The joint coverage of several sources (D-27): time is covered only where every source covers it. + /// + /// + /// + /// Each joint run is the stretch where one run of every source overlaps. Its + /// is the coarsest of those runs, because a combined value is only as + /// fine as its coarsest input, and it is only when all of them + /// are. Adjacent joint stretches with the same class and division merge into one run, as D-13 merges + /// consecutive intervals. A source without any coverage makes the joint coverage empty: missing input is + /// unknown, not zero (strict, D-27). + /// + /// + /// Every source's gap runs are carried into the result unchanged (sorted in with the joint runs, and + /// possibly overlapping one another), so a bucket of the virtual meter reports why a source is missing. + /// Joint runs do not know where any source's last interval starts, so their + /// is null: cap each source first (). + /// + /// + /// The joint runs' bounds are coverage bounds, not the bounds of any source interval: where one source's + /// coverage starts inside another's long interval, the joint run is a clipped piece of it. The D-14 edge + /// tolerance reads run bounds as interval bounds, so evaluate a virtual meter's buckets with + /// , which checks each source's resolution against its own runs. + /// + /// + public static IReadOnlyList Intersect(IReadOnlyList> perSource) + { + ArgumentNullException.ThrowIfNull(perSource); + + if (perSource.Count == 0) + { + return []; + } + + var sources = perSource + .Select(runs => runs.Where(r => !r.IsGap && r.To > r.From).OrderBy(r => r.From).ThenBy(r => r.To).ToList()) + .ToList(); + var joint = new List(); + + if (sources.TrueForAll(s => s.Count > 0)) + { + var edges = sources.SelectMany(s => s).SelectMany(r => new[] { r.From, r.To }).Distinct().Order().ToList(); + var cursors = new int[sources.Count]; + + for (var i = 0; i + 1 < edges.Count; i++) + { + var from = edges[i]; + var to = edges[i + 1]; + if (JointAt(sources, cursors, from, to) is not { } piece) + { + continue; + } + + if (joint.Count > 0 && joint[^1] is var last && last.To == from + && last.Resolution == piece.Resolution && last.DividedAtMonths == piece.DividedAtMonths) + { + joint[^1] = last with { To = to }; + } + else + { + joint.Add(new CoverageRun(from, to, piece.Resolution, piece.DividedAtMonths)); + } + } + } + + var gaps = perSource.SelectMany(runs => runs.Where(r => r.IsGap && r.To > r.From)); + return joint.Concat(gaps).OrderBy(r => r.From).ThenBy(r => r.To).ToList(); + } + + /// + /// The union of the non-gap runs as disjoint, sorted stretches, optionally clipped to [from, to). + /// + internal static List Union(IEnumerable runs, DateTimeOffset? from = null, DateTimeOffset? to = null) + { + var stretches = new List(); + foreach (var run in runs.Where(r => !r.IsGap).OrderBy(r => r.From)) + { + var start = from is { } lower && lower > run.From ? lower : run.From; + var end = to is { } upper && upper < run.To ? upper : run.To; + if (end <= start) + { + continue; + } + + if (stretches.Count > 0 && stretches[^1].To >= start) + { + if (end > stretches[^1].To) + { + stretches[^1] = stretches[^1] with { To = end }; + } + } + else + { + stretches.Add(new Stretch(start, end)); + } + } + + return stretches; + } + + private static CoveredSpan Summarize(List stretches) => + stretches.Count == 0 + ? CoveredSpan.None + : new CoveredSpan( + stretches.Aggregate(TimeSpan.Zero, (sum, s) => sum + s.Length), + stretches[0].From, + stretches[^1].To); + + /// The coarsest class and joint division of the runs covering [from, to) in every source, or null. + private static (ResolutionClass Resolution, bool DividedAtMonths)? JointAt( + List> sources, int[] cursors, DateTimeOffset from, DateTimeOffset to) + { + var resolution = ResolutionClass.Hour; + var divided = true; + + for (var s = 0; s < sources.Count; s++) + { + var runs = sources[s]; + while (cursors[s] < runs.Count && runs[cursors[s]].To <= from) + { + cursors[s]++; + } + + // Overlapping runs of one source are tolerated: the coarsest of those covering the stretch wins. + ResolutionClass? found = null; + var foundDivided = true; + for (var j = cursors[s]; j < runs.Count && runs[j].From <= from; j++) + { + if (runs[j].To < to) + { + continue; + } + + found = found is { } f ? ResolutionClassifier.Coarsest(f, runs[j].Resolution) : runs[j].Resolution; + foundDivided &= runs[j].DividedAtMonths; + } + + if (found is not { } sourceClass) + { + return null; + } + + resolution = ResolutionClassifier.Coarsest(resolution, sourceClass); + divided &= foundDivided; + } + + return (resolution, divided); + } +} + +/// +/// Covered time and its outer bounds. and are the first covered +/// instant and the end of the last covered stretch; the time between them need not all be covered — +/// says how much is. +/// +public sealed record CoveredSpan(TimeSpan Total, DateTimeOffset? First, DateTimeOffset? Last) +{ + public static CoveredSpan None { get; } = new(TimeSpan.Zero, null, null); + + public bool IsEmpty => First is null; +} + +/// A half-open stretch of time [From, To). +internal readonly record struct Stretch(DateTimeOffset From, DateTimeOffset To) +{ + public TimeSpan Length => To - From; +} diff --git a/src/Core/Analysis/Coverage/LifecycleCoverage.cs b/src/Core/Analysis/Coverage/LifecycleCoverage.cs new file mode 100644 index 0000000..bd6970d --- /dev/null +++ b/src/Core/Analysis/Coverage/LifecycleCoverage.cs @@ -0,0 +1,74 @@ +namespace MeterVault.Core.Analysis.Coverage; + +/// +/// The time outside a meter's service period as coverage (D-24): before its install date and after its retire date +/// a meter contributes a known zero to totals and to virtual evaluation, so a total over a retired meter and its +/// successor stays complete. +/// +/// +/// +/// A known zero is covered time without rows, and the coverage evaluator already reads it that way: an available +/// bucket with no rows is a true zero. So the stretches outside service are handed to it as runs of the finest +/// class, divided at months, that no bucket can find unresolved. They are only for the meter as a contributor +/// — a member of a total, a source of a virtual meter. The meter's own series keeps its real coverage: a bucket after +/// its retirement has no data of its own, and says so. +/// +/// +/// The service period is inclusive local dates ([InstalledAt, RetiredAt]); the zero stretches start and end at +/// local midnights of the instance zone and reach out to the supported dates (). A stretch +/// reaching past now is cut there by like any run: a retired meter's zero is known up +/// to now, not beyond. +/// +/// +public static class LifecycleCoverage +{ + /// The runs for the time outside [installedAt, retiredAt]; empty when neither date is set. + public static IReadOnlyList OutsideService(DateOnly? installedAt, DateOnly? retiredAt, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + + var runs = new List(2); + var earliest = CalendarEdges.Midnight(PeriodResolver.MinSupportedDate, zone); + var latest = CalendarEdges.Midnight(PeriodResolver.MaxSupportedDate.AddDays(1), zone); + + if (installedAt is { } installed && installed > PeriodResolver.MinSupportedDate) + { + var start = CalendarEdges.Midnight(installed, zone); + if (start > earliest) + { + runs.Add(ZeroRun(earliest, start)); + } + } + + if (retiredAt is { } retired && retired < PeriodResolver.MaxSupportedDate) + { + var end = CalendarEdges.Midnight(retired.AddDays(1), zone); + if (end < latest && (runs.Count == 0 || end > runs[0].To)) + { + runs.Add(ZeroRun(end, latest)); + } + } + + return runs; + } + + /// + /// with the time outside the service period added as known-zero coverage; the runs + /// themselves unchanged when neither date is set. + /// + public static IReadOnlyList WithService( + IReadOnlyList runs, DateOnly? installedAt, DateOnly? retiredAt, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(runs); + + var outside = OutsideService(installedAt, retiredAt, zone); + return outside.Count == 0 ? runs : [.. runs, .. outside]; + } + + /// True when the local day lies outside [installedAt, retiredAt]. + public static bool IsOutsideService(DateOnly day, DateOnly? installedAt, DateOnly? retiredAt) => + (installedAt is { } installed && day < installed) || (retiredAt is { } retired && day > retired); + + private static CoverageRun ZeroRun(DateTimeOffset from, DateTimeOffset to) => + new(from, to, ResolutionClass.Hour, DividedAtMonths: true); +} diff --git a/src/Core/Analysis/Coverage/MatchedCoverage.cs b/src/Core/Analysis/Coverage/MatchedCoverage.cs new file mode 100644 index 0000000..b386b45 --- /dev/null +++ b/src/Core/Analysis/Coverage/MatchedCoverage.cs @@ -0,0 +1,497 @@ +namespace MeterVault.Core.Analysis.Coverage; + +/// +/// The part of a comparison that both periods actually cover (D-07). A change figure is "confident" only +/// over this matched range; outside it, one side would be compared with nothing. +/// +/// +/// +/// The current period's covered time is shifted into the comparison period with the same calendar shift +/// the comparison itself uses (D-06), intersected with the comparison's coverage, and mapped back. A +/// year-to-date view whose data ends on 31 May therefore compares 1 January – 31 May with 1 January – +/// 31 May of the year before, not with the previous year's first eight and a half months. A side made of +/// several sources (a virtual meter, a total) covers only the time all of them cover. +/// +/// +/// Each end of the match is then trimmed to a place where the data of every source on both sides can be +/// cut: hour data is taken as is; inside month-aligned data () day +/// data snaps to local midnight, week data to Monday, and month or coarser data to the 1st; inside an +/// undivided run — whose intervals straddle month edges, so no calendar edge inside it is a place its rows +/// can be cut — only the run's own interval edges are (its ends, and where its last interval starts). A +/// boundary on such an edge needs no trimming. A snap never crosses the edge of its run, and it repeats until +/// it settles, because trimming one side moves the other through the shift, and aligning with one source can +/// land inside a coarser interval of another. +/// +/// +/// The bounds returned are query bounds for [From, To). A row is stamped exactly at the reading that +/// closes it unless that reading is at a local midnight (D-11), so a matched bound on a reading instant is +/// moved one later: the row closing the interval that ends there then falls inside +/// a range ending there and outside a range starting there — the side of the bound its time lies on. +/// +/// +/// An opening balance (D-14, A-01) is excluded: its row carries consumption of unknown extent, so a matched +/// range starts just after the instant it is booked at (its booked stamp, which for a midnight reading is the +/// midnight itself). Runs are capped at each side's "now" with , so stored +/// runs can be passed as they are (D-04). +/// +/// +/// The shift is passed in (current instant → comparison instant) so this composes with the period +/// resolver without depending on it. It must be monotone — non-decreasing — as every calendar shift is, +/// clamped month ends included; the inverse is found by bisection. +/// +/// +public static class MatchedCoverage +{ + /// + /// The smallest step past a row booked on a bound: one microsecond, PostgreSQL's timestamp precision, so + /// a query bound past the row survives the trip to the database. + /// + public static readonly TimeSpan PastBookedRow = TimeSpan.FromMicroseconds(1); + + private const int MaxAlignmentRounds = 32; + + /// Matches the coverage of a current period against that of its comparison period. + /// The period being viewed and its scope's coverage. + /// The comparison period and the same scope's coverage over it. + /// Maps an instant of the current period to the corresponding comparison instant. + /// + /// The matched pieces in time order, with their outer spans; + /// when nothing matches, which means absolute values only and no percentage (D-07). + /// + public static MatchedCoverageResult Match(MatchSide current, MatchSide comparison, Func shift) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(comparison); + ArgumentNullException.ThrowIfNull(shift); + + var currentSide = new Side(current); + var comparisonSide = new Side(comparison); + + var pieces = new List(); + foreach (var stretch in currentSide.Covered) + { + var shiftedFrom = shift(stretch.From); + var shiftedTo = shift(stretch.To); + + foreach (var other in comparisonSide.Covered) + { + var matchFrom = Later(shiftedFrom, other.From); + var matchTo = Earlier(shiftedTo, other.To); + if (matchTo <= matchFrom) + { + continue; + } + + var from = matchFrom == shiftedFrom ? stretch.From : FirstAtOrAfter(shift, matchFrom, stretch.From, stretch.To); + var to = matchTo == shiftedTo ? stretch.To : LastAtOrBefore(shift, matchTo, stretch.From, stretch.To); + + if (Trim(from, to, currentSide, comparisonSide, shift) is { } trimmed) + { + pieces.Add(new MatchedPiece( + currentSide.Range(trimmed.From, trimmed.To), + comparisonSide.Range(shift(trimmed.From), shift(trimmed.To)))); + } + } + } + + if (pieces.Count == 0) + { + return MatchedCoverageResult.NotComparable; + } + + pieces.Sort((a, b) => a.Current.From.CompareTo(b.Current.From)); + return new MatchedCoverageResult(Span(pieces, p => p.Current), Span(pieces, p => p.Comparison), pieces); + } + + /// Snaps both ends of a matched stretch to what both sides can cut at; null when nothing is left. + private static Stretch? Trim( + DateTimeOffset from, DateTimeOffset to, Side current, Side comparison, Func shift) + { + var start = from; + var end = to; + + // An opening balance inside the stretch moves its start there, and the new start has to be aligned + // again on both sides. + for (var round = 0; ; round++) + { + if (round == MaxAlignmentRounds || AlignStart(start, end, current, comparison, shift) is not { } aligned) + { + return null; + } + + start = aligned; + if (current.OpeningBalanceWithin(start, end) is { } own) + { + start = own; + } + else if (comparison.OpeningBalanceWithin(shift(start), shift(end)) is { } other + && FirstAtOrAfter(shift, other, start, end) is var mapped && mapped > start) + { + start = mapped; + } + else + { + break; + } + } + + var settled = false; + for (var round = 0; round < MaxAlignmentRounds && start < end; round++) + { + var aligned = current.AlignEnd(end); + var mapped = shift(aligned); + var mappedAligned = comparison.AlignEnd(mapped); + if (mappedAligned == mapped) + { + end = aligned; + settled = true; + break; + } + + end = LastAtOrBefore(shift, mappedAligned, start, aligned > start ? aligned : start); + } + + return settled && start < end ? new Stretch(start, end) : null; + } + + /// The first instant at or after both sides can start at; null when none before the end. + private static DateTimeOffset? AlignStart( + DateTimeOffset start, DateTimeOffset end, Side current, Side comparison, Func shift) + { + // Trimming one side moves the other through the shift, where it may land inside a coarse interval + // again; alternate until both sides agree. Every round only moves the start later. + for (var round = 0; round < MaxAlignmentRounds && start < end; round++) + { + var aligned = current.AlignStart(start); + var mapped = shift(aligned); + var mappedAligned = comparison.AlignStart(mapped); + if (mappedAligned == mapped) + { + return aligned < end ? aligned : null; + } + + start = FirstAtOrAfter(shift, mappedAligned, aligned < end ? aligned : end, end); + } + + return null; + } + + /// The smallest instant in [lo, hi] that shifts to or later; hi if none does. + private static DateTimeOffset FirstAtOrAfter( + Func shift, DateTimeOffset target, DateTimeOffset lo, DateTimeOffset hi) + { + if (shift(lo) >= target) + { + return lo; + } + + if (shift(hi) < target) + { + return hi; + } + + long below = lo.UtcTicks, atOrAbove = hi.UtcTicks; + while (atOrAbove - below > 1) + { + var mid = below + ((atOrAbove - below) / 2); + if (shift(Instant(mid)) >= target) + { + atOrAbove = mid; + } + else + { + below = mid; + } + } + + return Instant(atOrAbove); + } + + /// The largest instant in [lo, hi] that shifts to or earlier; lo if none does. + private static DateTimeOffset LastAtOrBefore( + Func shift, DateTimeOffset target, DateTimeOffset lo, DateTimeOffset hi) + { + if (shift(hi) <= target) + { + return hi; + } + + if (shift(lo) > target) + { + return lo; + } + + long atOrBelow = lo.UtcTicks, above = hi.UtcTicks; + while (above - atOrBelow > 1) + { + var mid = atOrBelow + ((above - atOrBelow) / 2); + if (shift(Instant(mid)) <= target) + { + atOrBelow = mid; + } + else + { + above = mid; + } + } + + return Instant(atOrBelow); + } + + private static MatchedRange Span(List pieces, Func side) => + new(side(pieces[0]).From, side(pieces[^1]).To, side(pieces[0]).FirstDay, side(pieces[^1]).LastDay); + + private static DateTimeOffset Instant(long utcTicks) => new(utcTicks, TimeSpan.Zero); + + private static DateTimeOffset Earlier(DateTimeOffset a, DateTimeOffset b) => a <= b ? a : b; + + private static DateTimeOffset Later(DateTimeOffset a, DateTimeOffset b) => a >= b ? a : b; + + /// One side of the comparison: its sources' runs as of its now, their edges, and the calendar they are cut in. + private sealed class Side + { + private readonly TimeZoneInfo _zone; + + private readonly List> _sources; + + private readonly HashSet _edges = []; + + private readonly List _openingBalances; + + public Side(MatchSide side) + { + ArgumentNullException.ThrowIfNull(side.Zone, nameof(side)); + ArgumentNullException.ThrowIfNull(side.Sources, nameof(side)); + ArgumentNullException.ThrowIfNull(side.OpeningBalances, nameof(side)); + + _zone = side.Zone; + _openingBalances = [.. side.OpeningBalances.Select(b => b.ToUniversalTime()).Order()]; + + // Where the data can be cut is a property of the stored runs: the end of a run cut at now is not + // an interval edge, and snapping to it would pass a straddling interval off as whole. What is + // covered is the runs as of now (A-04). + _sources = side.Sources.Select(runs => runs.Where(r => !r.IsGap && r.To > r.From).ToList()).ToList(); + foreach (var run in side.Sources.SelectMany(runs => runs).Where(r => r.To > r.From)) + { + // Every run edge is an interval edge — including a gap's, whose row is booked at its end. + _edges.Add(run.From); + _edges.Add(run.To); + if (run.LastIntervalStart is { } last) + { + _edges.Add(last); + } + } + + var capped = side.Sources.Select(runs => CoverageRuns.CapAt(runs, side.Now, _zone)).ToList(); + Covered = capped.Count == 0 + ? [] + : CoverageRuns.Union(CoverageRuns.Intersect(capped), side.From, Earlier(side.To, side.Now)); + } + + /// The time every source covers inside the side's range, up to its now, as disjoint stretches. + public List Covered { get; } + + /// The first instant at or after every source's data can start a range at. + public DateTimeOffset AlignStart(DateTimeOffset start) => Settle(start, SnapStart); + + /// The last instant at or before every source's data can end a range at. + public DateTimeOffset AlignEnd(DateTimeOffset end) => Settle(end, SnapEnd); + + /// The last opening balance booked strictly inside (from, to), if any. + public DateTimeOffset? OpeningBalanceWithin(DateTimeOffset from, DateTimeOffset to) + { + DateTimeOffset? last = null; + foreach (var booking in _openingBalances.Where(b => b > from && b < to)) + { + last = booking; + } + + return last; + } + + /// + /// The query range for the matched stretch [start, end): a start on an opening balance, or on a + /// reading instant that is not a local midnight, moves past the row booked there; so does an end on such + /// a reading instant, unless an opening balance is booked there too — that row must stay out. + /// + public MatchedRange Range(DateTimeOffset start, DateTimeOffset end) + { + var openingAtStart = _openingBalances.BinarySearch(start) >= 0; + var from = openingAtStart || OnReading(start) ? start + PastBookedRow : start; + var to = OnReading(end) && _openingBalances.BinarySearch(end) < 0 ? end + PastBookedRow : end; + + return new MatchedRange(from, to, CalendarEdges.LocalDate(start, _zone), CalendarEdges.LocalDate(end > start ? end.AddTicks(-1) : end, _zone)); + } + + private bool OnReading(DateTimeOffset instant) => _edges.Contains(instant) && !CalendarEdges.IsMidnight(instant, _zone); + + private DateTimeOffset Settle(DateTimeOffset instant, Func, DateTimeOffset, DateTimeOffset> snap) + { + var current = instant; + for (var step = 0; step < MaxAlignmentRounds; step++) + { + var next = current; + foreach (var source in _sources) + { + next = snap(source, next); + } + + if (next == current) + { + break; + } + + current = next; + } + + return current; + } + + /// + /// One snap of a range start against one source: to the next place the containing run can be cut, but + /// never past the run's own end, which is always an interval edge. + /// + private DateTimeOffset SnapStart(List runs, DateTimeOffset start) + { + if (Coarsest(runs, r => r.From <= start && start < r.To) is not { } run + || run.From == start || run.LastIntervalStart == start || run.Resolution == ResolutionClass.Hour) + { + return start; + } + + DateTimeOffset snapped; + if (run.DividedAtMonths) + { + snapped = CalendarEdges.IsMonthStart(start, _zone) ? start : run.Resolution switch + { + ResolutionClass.Day => CalendarEdges.Ceiling(start, CalendarUnit.Day, _zone), + ResolutionClass.Week => CalendarEdges.Ceiling(start, CalendarUnit.Week, _zone), + _ => CalendarEdges.Ceiling(start, CalendarUnit.Month, _zone), + }; + } + else + { + snapped = run.LastIntervalStart is { } last && last > start ? last : run.To; + } + + return snapped < run.To ? snapped : run.To; + } + + /// + /// One snap of a range end against one source: to the previous place the containing run can be cut, but + /// never before the run's own start, which is always an interval edge. + /// + private DateTimeOffset SnapEnd(List runs, DateTimeOffset end) + { + if (Coarsest(runs, r => r.From < end && end <= r.To) is not { } run + || run.To == end || run.LastIntervalStart == end || run.Resolution == ResolutionClass.Hour) + { + return end; + } + + DateTimeOffset snapped; + if (run.DividedAtMonths) + { + snapped = CalendarEdges.IsMonthStart(end, _zone) ? end : run.Resolution switch + { + ResolutionClass.Day => CalendarEdges.Floor(end, CalendarUnit.Day, _zone), + ResolutionClass.Week => CalendarEdges.Floor(end, CalendarUnit.Week, _zone), + _ => CalendarEdges.Floor(end, CalendarUnit.Month, _zone), + }; + } + else + { + snapped = run.LastIntervalStart is { } last && last < end ? last : run.From; + } + + return snapped > run.From ? snapped : run.From; + } + + /// The coarsest run satisfying , an undivided one on a tie. + private static CoverageRun? Coarsest(List runs, Func contains) + { + CoverageRun? coarsest = null; + foreach (var run in runs.Where(contains)) + { + if (coarsest is null || run.Resolution > coarsest.Resolution + || (run.Resolution == coarsest.Resolution && coarsest.DividedAtMonths && !run.DividedAtMonths)) + { + coarsest = run; + } + } + + return coarsest; + } + } +} + +/// +/// One side of a comparison as reads it (m2 #6): the requested range +/// [From, To), the captured "now" coverage is capped at, the zone its calendar is cut in, the coverage +/// runs of each source of the scope (stored runs are fine), and the booked stamps of those sources' opening +/// balances (A-01). +/// +/// The start of the requested range. +/// The end of the requested range (exclusive): a local midnight or the mapped cut-off. +/// The captured now; nothing after it counts as covered (D-04). +/// The instance zone. +/// +/// The runs of every source of the scope — one list for a single meter; one per source for a virtual meter or +/// a total, where only the time all of them cover counts, and every source's interval edges must agree. +/// +/// Where the sources' opening-balance rows are booked (their stamps). +public sealed record MatchSide( + DateTimeOffset From, + DateTimeOffset To, + DateTimeOffset Now, + TimeZoneInfo Zone, + IReadOnlyList> Sources, + IReadOnlyList OpeningBalances) +{ + /// A side covered by one meter's runs. + public static MatchSide Of( + DateTimeOffset from, + DateTimeOffset to, + DateTimeOffset now, + TimeZoneInfo zone, + IReadOnlyList runs, + IReadOnlyList? openingBalances = null) => + new(from, to, now, zone, [runs], openingBalances ?? []); + + /// A side covered jointly by several sources (a virtual meter's, or the meters of a total) (m2 #4). + public static MatchSide OfSources( + DateTimeOffset from, + DateTimeOffset to, + DateTimeOffset now, + TimeZoneInfo zone, + IReadOnlyList> sources, + IReadOnlyList? openingBalances = null) => + new(from, to, now, zone, sources, openingBalances ?? []); +} + +/// +/// A matched stretch of one period: query bounds [From, To) and inclusive local dates. A bound on a +/// reading instant is one past it (see ); +/// the dates are those of the stretch itself. +/// +public sealed record MatchedRange(DateTimeOffset From, DateTimeOffset To, DateOnly FirstDay, DateOnly LastDay); + +/// One matched stretch in both periods: the same calendar stretch, covered on both sides. +public sealed record MatchedPiece(MatchedRange Current, MatchedRange Comparison); + +/// +/// The matched coverage of a comparison (D-07). and are the +/// outer spans shown next to the requested ranges; are what is actually matched. When a +/// hole on either side splits the match, the spans include time that is not matched — a confident change +/// figure sums the pieces, not the spans. +/// +public sealed record MatchedCoverageResult(MatchedRange? Current, MatchedRange? Comparison, IReadOnlyList Pieces) +{ + /// Nothing matches: show absolute values only, no percentage (D-07). + public static MatchedCoverageResult NotComparable { get; } = new(null, null, []); + + public bool IsComparable => Pieces.Count > 0; + + /// True when the match is one unbroken stretch, so the spans are exactly what is matched. + public bool IsContiguous => Pieces.Count == 1; +} diff --git a/src/Core/Analysis/Coverage/ProvenanceRules.cs b/src/Core/Analysis/Coverage/ProvenanceRules.cs new file mode 100644 index 0000000..5502d3f --- /dev/null +++ b/src/Core/Analysis/Coverage/ProvenanceRules.cs @@ -0,0 +1,50 @@ +namespace MeterVault.Core.Analysis.Coverage; + +/// +/// Where a value comes from (D-14, brief §4.3) — a dimension separate from its status. A complete bucket +/// can rest on estimated shares, and a partial one can be entirely measured, so the two are never folded +/// into one flag. +/// +public static class ProvenanceRules +{ + /// An amount below this is float noise from dividing and re-summing, not a contribution. + public const double Epsilon = 1e-9; + + /// + /// The provenance of a bucket from its per-quality amounts, as the rollups store them (D-12). A quality + /// contributes when its amount is not zero: a negative share (savings, a grid balance) is still data of + /// that quality, so the sign does not matter. + /// + /// The amount booked from measured readings. + /// The amount booked from manually entered readings. + /// The amount booked from imported readings. + /// The amount divided across months, coalesced or otherwise inferred. + /// True when a first reading with an unknown start is booked in the bucket. + /// True for a value calculated from other meters (virtual). + /// for a bucket without any contribution — a true zero has no source. + public static Provenance ProvenanceOf( + double measured, double manual, double imported, double estimated, bool openingBalance, bool derived) + { + var provenance = Provenance.None; + provenance |= Contributes(measured) ? Provenance.Measured : Provenance.None; + provenance |= Contributes(manual) ? Provenance.Manual : Provenance.None; + provenance |= Contributes(imported) ? Provenance.Imported : Provenance.None; + provenance |= Contributes(estimated) ? Provenance.Estimated : Provenance.None; + provenance |= openingBalance ? Provenance.OpeningBalance : Provenance.None; + provenance |= derived ? Provenance.Derived : Provenance.None; + return provenance; + } + + /// + /// The provenance of a value calculated from sources (D-27): everything its inputs rest on, plus + /// . A derived value built from estimated input stays estimated. + /// + public static Provenance Derive(IEnumerable sources) + { + ArgumentNullException.ThrowIfNull(sources); + + return sources.Aggregate(Provenance.Derived, (all, source) => all | source); + } + + private static bool Contributes(double amount) => Math.Abs(amount) > Epsilon; +} diff --git a/src/Core/Analysis/Coverage/ResolutionClassifier.cs b/src/Core/Analysis/Coverage/ResolutionClassifier.cs new file mode 100644 index 0000000..6c1e85e --- /dev/null +++ b/src/Core/Analysis/Coverage/ResolutionClassifier.cs @@ -0,0 +1,88 @@ +namespace MeterVault.Core.Analysis.Coverage; + +/// +/// The one place that sorts time into es (D-13, A-09): the class of a source +/// interval, the class a bucket size needs, and how long an interval of a class can be. +/// +/// +/// +/// The classes are calendar ideas — an hour, a day, a week, a month — but source intervals are measured in +/// elapsed time, and real intervals are not exact: an hourly poll arrives a few seconds late, a daily snapshot +/// spans the 25-hour autumn DST day, a weekly reading is taken an hour later than the week before, and a +/// month is up to 31 days plus a DST hour, with room for a reading taken a little after the month's local +/// midnight. Each limit therefore carries enough slack that the longest honest interval of a class still +/// lands in it — otherwise every autumn DST day would read as week-resolution data and its day bucket would +/// turn unresolved once a year. Each limit is inclusive; anything longer than a month is +/// . +/// +/// +/// A bucket size maps to the coarsest class that can resolve it (). A year +/// is a whole number of local months, so month-resolution data can resolve years too. For month and year +/// buckets only day-or-finer data resolves on its class alone; week and month data must have been divided at +/// the month edges or stay within the D-14 edge tolerance, and anything coarser has to prove itself interval +/// by interval (). +/// +/// +public static class ResolutionClassifier +{ + /// The longest interval that still resolves hours: an hour and a minute of poll jitter. + public static readonly TimeSpan HourLimit = TimeSpan.FromHours(1) + TimeSpan.FromMinutes(1); + + /// The longest interval that still resolves days: 25 hours, a day that leaves daylight time. + public static readonly TimeSpan DayLimit = TimeSpan.FromHours(25); + + /// The longest interval that still resolves weeks: seven days and a DST hour. + public static readonly TimeSpan WeekLimit = TimeSpan.FromDays(7) + TimeSpan.FromHours(1); + + /// The longest interval that still resolves months: 31 days, a DST hour and an hour of slack. + public static readonly TimeSpan MonthLimit = TimeSpan.FromDays(31) + TimeSpan.FromHours(2); + + /// + /// The class of one source interval of this length. Zero and negative lengths (an instant, or bounds a + /// caller passed the wrong way round) are the finest class: they claim no stretch of time. + /// + public static ResolutionClass Classify(TimeSpan length) => + length <= HourLimit ? ResolutionClass.Hour + : length <= DayLimit ? ResolutionClass.Day + : length <= WeekLimit ? ResolutionClass.Week + : length <= MonthLimit ? ResolutionClass.Month + : ResolutionClass.Coarse; + + /// The class of the interval [from, to). + public static ResolutionClass Classify(DateTimeOffset from, DateTimeOffset to) => Classify(to - from); + + /// + /// The longest interval a class admits — how far a run of that class may stop short of a bucket end and + /// still be up to date (A-04). has no limit. + /// + public static TimeSpan LimitOf(ResolutionClass resolution) => resolution switch + { + ResolutionClass.Hour => HourLimit, + ResolutionClass.Day => DayLimit, + ResolutionClass.Week => WeekLimit, + ResolutionClass.Month => MonthLimit, + _ => TimeSpan.MaxValue, + }; + + /// + /// The coarsest run class that can resolve a bucket of this size: a day needs day-or-finer data, a week + /// week-or-finer, a month and a year month-or-finer. Day and week buckets take it without further checks. + /// For month and year buckets a week- or month-class run must also have been divided at the month edges + /// or stay within the D-14 edge tolerance; coarser runs can still resolve a bucket the same way — + /// decides that. + /// + /// + /// For : auto is resolved to a concrete size before any bucket exists (D-05). + /// + public static ResolutionClass CoarsestResolving(BucketSize size) => size switch + { + BucketSize.Day => ResolutionClass.Day, + BucketSize.Week => ResolutionClass.Week, + BucketSize.Month => ResolutionClass.Month, + BucketSize.Year => ResolutionClass.Month, + _ => throw new ArgumentOutOfRangeException(nameof(size), size, "Only concrete bucket sizes have a resolution class."), + }; + + /// The coarser of two classes — the resolution of values that combine both (D-27). + public static ResolutionClass Coarsest(ResolutionClass a, ResolutionClass b) => a >= b ? a : b; +} diff --git a/src/Core/Analysis/Freshness.cs b/src/Core/Analysis/Freshness.cs new file mode 100644 index 0000000..05dbaea --- /dev/null +++ b/src/Core/Analysis/Freshness.cs @@ -0,0 +1,145 @@ +namespace MeterVault.Core.Analysis; + +/// How current a meter's data is (D-18). A dimension of its own: a stale meter can still have complete history. +public enum FreshnessState +{ + /// No reading or event has ever been recorded (or, for a calculation, none of its sources has one). + NoData, + + /// + /// Only imported or manually entered data: nothing is expected to arrive on its own, so the meter is never + /// stale — its data simply ends where it ends. + /// + Historical, + + /// A live source (MQTT, Tasmota, Home Assistant) delivers, and its last value is recent enough. + Live, + + /// A live source has not delivered for longer than its own rhythm allows. + Stale, +} + +/// +/// A meter's freshness (D-18): its state, the last reading or event time it rests on, and — for a live meter — +/// after how long without data it counts as stale. +/// +public sealed record Freshness(FreshnessState State, DateTimeOffset? LastActivity, TimeSpan? StaleAfter = null) +{ + public static Freshness None { get; } = new(FreshnessState.NoData, null); +} + +/// What needs about one meter. +/// The latest reading's time, or null. +/// The latest event's time, or null. +/// The latest readings' times (up to ), any order. +/// True when an enabled MQTT, Tasmota or Home Assistant source feeds the meter. +/// The longest poll interval among its polled sources; null for push-only sources. +public sealed record FreshnessInput( + DateTimeOffset? LastReading, + DateTimeOffset? LastEvent, + IReadOnlyList RecentReadings, + bool HasLiveSource, + TimeSpan? PollInterval); + +/// +/// The staleness rule of D-18: the last reading or event time is the freshness mark, and a live source is stale when +/// that mark is older than the larger of three times the median of its last 20 reading intervals and three times its +/// poll interval. A meter fed only by imports or by hand is historical, never stale. +/// +/// +/// The median follows the meter's own rhythm — a Tasmota plug reporting every ten seconds is late after a minute, a +/// daily push after three days — and the poll interval keeps a rarely polled source from being called stale between +/// two polls. A live meter whose rhythm is unknown (a single reading from a push source) is not called stale: nothing +/// says how often it should arrive. +/// +public static class FreshnessRules +{ + /// How many recent reading times the median is taken over: 20 intervals need 21 readings. + public const int RecentReadingCount = 21; + + /// How many intervals (or poll intervals) of silence make a live source stale. + public const double StaleFactor = 3; + + /// The freshness of one meter as of . + public static Freshness Evaluate(FreshnessInput input, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(input); + + var last = Latest(input.LastReading, input.LastEvent); + if (last is not { } mark) + { + return Freshness.None; + } + + if (!input.HasLiveSource) + { + return new Freshness(FreshnessState.Historical, mark); + } + + var median = MedianInterval(input.RecentReadings); + TimeSpan? threshold = null; + if (median is { } m) + { + threshold = m * StaleFactor; + } + + if (input.PollInterval is { } poll && poll > TimeSpan.Zero) + { + var byPoll = poll * StaleFactor; + threshold = threshold is { } t && t >= byPoll ? t : byPoll; + } + + var stale = threshold is { } limit && now - mark > limit; + return new Freshness(stale ? FreshnessState.Stale : FreshnessState.Live, mark, threshold); + } + + /// + /// The median of the intervals between the latest reading times; null with fewer + /// than two distinct times. + /// + public static TimeSpan? MedianInterval(IReadOnlyList times) + { + ArgumentNullException.ThrowIfNull(times); + + var recent = times.Select(t => t.ToUniversalTime()).Distinct().OrderDescending().Take(RecentReadingCount).Order().ToList(); + if (recent.Count < 2) + { + return null; + } + + var intervals = new List(recent.Count - 1); + for (var i = 1; i < recent.Count; i++) + { + intervals.Add(recent[i] - recent[i - 1]); + } + + intervals.Sort(); + var middle = intervals.Count / 2; + return intervals.Count % 2 == 1 ? intervals[middle] : (intervals[middle - 1] + intervals[middle]) / 2; + } + + /// + /// The freshness of a value resting on several meters (a virtual meter, a total): stale when any of them is, + /// live when any delivers live, else historical, else no data. The mark is the oldest one among the meters that + /// have data — a sum is only as current as its least current source. + /// + public static Freshness Combine(IEnumerable parts) + { + ArgumentNullException.ThrowIfNull(parts); + + var list = parts.Where(p => p.State != FreshnessState.NoData).ToList(); + if (list.Count == 0) + { + return Freshness.None; + } + + var state = list.Exists(p => p.State == FreshnessState.Stale) ? FreshnessState.Stale + : list.Exists(p => p.State == FreshnessState.Live) ? FreshnessState.Live + : FreshnessState.Historical; + var oldest = list.Where(p => p.LastActivity is not null).Select(p => p.LastActivity!.Value).DefaultIfEmpty().Min(); + return new Freshness(state, list.Exists(p => p.LastActivity is not null) ? oldest : null); + } + + private static DateTimeOffset? Latest(DateTimeOffset? a, DateTimeOffset? b) => + a is { } x && b is { } y ? (x >= y ? x : y) : a ?? b; +} diff --git a/src/Core/Analysis/Quantities/MeterRoleRules.cs b/src/Core/Analysis/Quantities/MeterRoleRules.cs new file mode 100644 index 0000000..0878f3d --- /dev/null +++ b/src/Core/Analysis/Quantities/MeterRoleRules.cs @@ -0,0 +1,234 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Quantities; + +/// +/// A meter's part in its energy type's balance (D-21, D-22). Stored in as the +/// token; this enum is the typed view analysis and the editor work with. +/// +public enum MeterRole +{ + /// Measures everything the site uses (total_load). It becomes the type's "use" measure. + TotalLoad, + + /// Measures what is drawn from the grid (grid_import). It is what the supplier bills. + GridImport, + + /// Measures what is fed into the grid (grid_export). It is export, never consumption. + GridExport, +} + +/// +/// A meter as the role rules see it: which type it belongs to, what it measures, the role token it stores and +/// whether it is retired (A-07). +/// +/// +/// The retired flag is an input rather than something the rules work out, because "retired" is the caller's +/// lifecycle decision: takes a recorded to mean retired, and a +/// caller that also treats a deactivated meter as retired builds the candidate itself. The energy type is an +/// so that analysis inputs, which carry it as one, need no cast. +/// +/// The meter's id. +/// The energy type roles are unique within. +/// The meter's measurement mode; it decides which roles the meter may hold. +/// The raw role token from , or null. +/// True when the meter is out of service: it keeps its role but never competes for one. +public sealed record RoleCandidate(int MeterId, int EnergyTypeId, MeterMode Mode, string? RoleToken, bool IsRetired) +{ + /// The role the stored token names, whatever the mode (see ). + public MeterRole? StoredRole => MeterRoleRules.Parse(RoleToken); + + /// The role the meter actually plays (see ). + public MeterRole? EffectiveRole => MeterRoleRules.Effective(Mode, RoleToken); + + /// A persisted meter; it is retired when its lifecycle end is recorded. + public static RoleCandidate From(Meter meter) + { + ArgumentNullException.ThrowIfNull(meter); + + return new RoleCandidate(meter.Id, meter.EnergyTypeId, meter.Mode, MeterMeta.Role(meter.Meta), meter.RetiredAt is not null); + } +} + +/// +/// Parsing, mode rules and per-type uniqueness of (D-21, A-07). +/// +/// +/// +/// A role says which meter answers a question about the whole energy type — how much did the site use, how +/// much came from the grid, how much went back — so two meters in service holding it would give two answers. +/// Roles are therefore unique per energy type among the meters that are not retired, and assigning a role to +/// a meter in service moves it: +/// names the meters to clear and +/// the one to tell the user about. +/// +/// +/// A retired meter keeps its role (A-07). When the grid meter is replaced by a new meter record, the old one +/// measured the grid import of its own service period, and stripping its role would turn that history into +/// an unexplained submeter and drop it from the bill. Each holder counts only within its own service period +/// (D-24), so a retired holder and its successor never answer for the same day. A retired meter neither +/// loses its role to a new holder nor takes it from one. +/// +/// +/// Only a meter that measures a flow in its own right can hold one: a cumulative register, a direct-delta +/// feed or an instant-rate sensor. A generation counter measures production (its own measure), a runtime +/// counter measures hours, a tank measures a store, and a virtual meter is an analysis view over other meters. +/// A role written onto such a meter — by an older editor or a restored backup — is still a stored token and +/// still counts as a holder so the next assignment clears it, but it has no +/// meaning, and a meter that really plays the role is always named before it. +/// +/// +public static class MeterRoleRules +{ + /// Every role, in display order. + public static IReadOnlyList All { get; } = [MeterRole.TotalLoad, MeterRole.GridImport, MeterRole.GridExport]; + + private static readonly IReadOnlyList None = []; + + /// + /// Reads a stored role token (total_load, grid_import, grid_export). Surrounding + /// whitespace and case are ignored; anything else — including null, blank and unknown tokens — is not a + /// role. + /// + public static bool TryParse(string? token, out MeterRole role) + { + role = default; + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + foreach (var candidate in All) + { + if (string.Equals(Token(candidate), token.Trim(), StringComparison.OrdinalIgnoreCase)) + { + role = candidate; + return true; + } + } + + return false; + } + + /// The role a stored token names, or null (see ). + public static MeterRole? Parse(string? token) => TryParse(token, out var role) ? role : null; + + /// The token stored in for . + public static string Token(MeterRole role) => role switch + { + MeterRole.TotalLoad => MeterRoles.TotalLoad, + MeterRole.GridImport => MeterRoles.GridImport, + MeterRole.GridExport => MeterRoles.GridExport, + _ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown meter role."), + }; + + /// The roles a meter of may hold; the editor offers only these. + public static IReadOnlyList AllowedFor(MeterMode mode) => mode switch + { + MeterMode.CumulativeCounter or MeterMode.DirectDelta or MeterMode.InstantRate => All, + _ => None, + }; + + /// True when a meter of may hold . + public static bool IsAllowed(MeterRole role, MeterMode mode) => AllowedFor(mode).Contains(role); + + /// + /// The role a meter actually plays: the stored token when it names a role its mode may hold, otherwise + /// null. Analysis (kinds, totals, billing) reads this, never the raw token. + /// + public static MeterRole? Effective(MeterMode mode, string? token) => + TryParse(token, out var role) && IsAllowed(role, mode) ? role : null; + + /// The role plays (see ). + public static MeterRole? Effective(Meter meter) + { + ArgumentNullException.ThrowIfNull(meter); + + return Effective(meter.Mode, MeterMeta.Role(meter.Meta)); + } + + /// + /// The meters of energy type in service that store , + /// other than (the meter being saved). Meters that really play the role + /// come first, then invalid leftovers, each by id. Retired meters are never listed: they keep their role. + /// Normally there is at most one; legacy data may have several, and every one of them has to give the role + /// up. A stored token counts whatever the holder's mode, so an invalid leftover is cleared too. + /// + public static IReadOnlyList Holders( + IEnumerable meters, MeterRole role, int energyTypeId, int? exceptMeterId = null) + { + ArgumentNullException.ThrowIfNull(meters); + + return meters + .Where(m => m.EnergyTypeId == energyTypeId + && m.MeterId != exceptMeterId + && !m.IsRetired + && m.StoredRole == role) + .OrderByDescending(m => IsAllowed(role, m.Mode)) + .ThenBy(m => m.MeterId) + .ToList(); + } + + /// + /// The persisted meters that store in service (see + /// ); a meter is retired when its + /// is recorded. + /// + public static IReadOnlyList Holders( + IEnumerable meters, MeterRole role, short energyTypeId, int? exceptMeterId = null) + { + ArgumentNullException.ThrowIfNull(meters); + + var meterOf = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var meter in meters) + { + meterOf[RoleCandidate.From(meter)] = meter; + } + + return Holders(meterOf.Keys, role, energyTypeId, exceptMeterId).Select(c => meterOf[c]).ToList(); + } + + /// + /// The meter the role moves away from when takes it: the holder in service that + /// really plays the role, else an invalid leftover, else null when the role is free. See + /// for legacy duplicates. + /// + public static RoleCandidate? CurrentHolder(IEnumerable meters, MeterRole role, int energyTypeId, int meterId) => + Holders(meters, role, energyTypeId, meterId).FirstOrDefault(); + + /// The persisted meter the role moves away from (see the overload). + public static Meter? CurrentHolder(IEnumerable meters, MeterRole role, short energyTypeId, int meterId) => + Holders(meters, role, energyTypeId, meterId).FirstOrDefault(); + + /// + /// The meters that give up when is saved holding it: every + /// other holder in service in its energy type, the one that really played the role first. None when the + /// taker is retired (it keeps the role for its history but displaces no meter in service), or when its + /// mode cannot hold the role (the editor never offers that, and a token that means nothing must not clear + /// one that does). + /// + /// The meters of the installation; the taker's stored version may be among them. + /// The meter being saved, as it will be stored. + /// The role it is saved with. + public static IReadOnlyList Displaced(IEnumerable meters, RoleCandidate taker, MeterRole role) + { + ArgumentNullException.ThrowIfNull(meters); + ArgumentNullException.ThrowIfNull(taker); + + return taker.IsRetired || !IsAllowed(role, taker.Mode) + ? [] + : Holders(meters, role, taker.EnergyTypeId, taker.MeterId); + } + + /// The persisted meters that give the role up (see the overload). + public static IReadOnlyList Displaced(IEnumerable meters, Meter taker, MeterRole role) + { + ArgumentNullException.ThrowIfNull(meters); + ArgumentNullException.ThrowIfNull(taker); + + var candidate = RoleCandidate.From(taker); + return candidate.IsRetired || !IsAllowed(role, candidate.Mode) + ? [] + : Holders(meters, role, taker.EnergyTypeId, taker.Id); + } +} diff --git a/src/Core/Analysis/Quantities/NormalizedQuantity.cs b/src/Core/Analysis/Quantities/NormalizedQuantity.cs new file mode 100644 index 0000000..a5086cf --- /dev/null +++ b/src/Core/Analysis/Quantities/NormalizedQuantity.cs @@ -0,0 +1,276 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Quantities; + +/// Why a normalized quantity is less certain than its mode alone suggests. Combinable. +[Flags] +public enum QuantityNotes +{ + None = 0, + + /// + /// Runtime converted to the tank's unit with the fixed nozzle rate: a derived estimate, not a measured + /// volume (D-20). The kind stays . + /// + FixedRateEstimate = 1, + + /// + /// The instant-rate unit is not a rate at all ("kWh", "Stk"), so the analysis assumes — as the normalizer + /// does — that the value is a rate per hour of the meter's unit. + /// + RateAssumedPerHour = 2, + + /// The tank has no unit (or no tank exists), so a fallback unit is used. + NoTankUnit = 4, + + /// + /// A virtual meter without a usable declared result (D-25, D-28 "needs configuration"): consumption in + /// the meter's unit is assumed until the definition is saved. + /// + UndeclaredResult = 8, + + /// The meter stores a role its mode cannot hold (D-21); the role was ignored. + RoleIgnored = 16, + + /// + /// The instant-rate unit is a rate over something other than an hour ("L/min", "m³/s"). The normalizer + /// integrates per hour and does not rescale, so the amounts are not in the rate's quantity unit; the rate + /// unit is kept so that no tariff or total takes them for one. The source needs a scale to a rate per hour. + /// + RateNotPerHour = 32, + + /// + /// A fixed nozzle rate (per hour) multiplies a runtime register that counts minutes or seconds: the + /// normalizer does not convert the register, so the volume is off by the register's scale (1/60 for + /// minutes). The editor should ask for an hour register or a rate in the register's unit. + /// + RegisterNotInHours = 64, +} + +/// +/// The tank facts that decide a meter's normalized unit: the store's unit and whether runtime is converted +/// to it at a fixed rate. Mirrors the fields +/// and act on. +/// +public sealed record TankInfo(string? Unit, TankRateMode RateMode, double? FixedRate) +{ + /// Projects a persisted ; null stays null. + public static TankInfo? From(Tank? tank) => tank is null ? null : new TankInfo(tank.Unit, tank.RateMode, tank.FixedRate); + + /// + /// True when the runtime normalizer multiplies the register by . It does so for any + /// configured rate in mode — and only then; a Fixed tank without a rate + /// books the plain register. + /// + public bool ConvertsRuntime => RateMode == TankRateMode.Fixed && FixedRate is not null; +} + +/// +/// What a virtual meter declares its formula yields (D-25 resultKind/resultUnit). Only +/// can be declared (A-08): a virtual result is consumption, generation, a signed +/// net balance or a non-additive indicator. +/// +public sealed record DeclaredVirtualResult(QuantityKind Kind, string? Unit) +{ + /// The result kinds D-25 lets a virtual meter declare. + public static IReadOnlyList AllowedKinds { get; } = + [QuantityKind.Consumption, QuantityKind.Generation, QuantityKind.Net, QuantityKind.Indicator]; + + /// True when is one a virtual meter may declare. + public bool HasAllowedKind => AllowedKinds.Contains(Kind); + + /// + /// The result a legacy virtual meter's implied sum has (D-28): its sources' common kind and unit, or null + /// when the sum needs configuration. + /// + /// + /// Until D-28 converts it, an expression-less virtual meter is evaluated as the sum of its incoming links + /// ("legacy — confirm"). Labelling that sum with the meter's own unit and consumption would call Summe + /// Solar (Solar 1 + Solar 2) consumption, so the reader passes the result this derives instead. A sum only + /// has a result when every source has the same additive kind a virtual meter may declare (consumption, + /// generation or net) and the same unit under ; mixed kinds need a declared + /// net, indicators are never added, and export, runtime or cost are not virtual results, so all of those + /// give null, which + /// reports as . + /// + /// The normalized kind of each source, in source order. + /// The normalized unit of each source, in the same order. + public static DeclaredVirtualResult? FromSources(IEnumerable kinds, IEnumerable units) + { + ArgumentNullException.ThrowIfNull(kinds); + ArgumentNullException.ThrowIfNull(units); + + var kindList = kinds.ToList(); + var unitList = units.ToList(); + if (kindList.Count != unitList.Count) + { + throw new ArgumentException("Every source needs exactly one kind and one unit.", nameof(units)); + } + + if (kindList.Count == 0 + || kindList.Distinct().Count() != 1 + || kindList[0] is not (QuantityKind.Consumption or QuantityKind.Generation or QuantityKind.Net) + || unitList.Distinct(Units.Comparer).Count() != 1) + { + return null; + } + + var unit = Units.Normalize(unitList[0]); + return new DeclaredVirtualResult(kindList[0], unit.Length > 0 ? unit : null); + } + + /// + /// The result of a legacy sum over sources whose normalized quantities are known (see the other overload). + /// A source that is itself an undeclared virtual meter only has an assumed kind, so a sum over it has no + /// result either. + /// + public static DeclaredVirtualResult? FromSources(IEnumerable sources) + { + ArgumentNullException.ThrowIfNull(sources); + + var list = sources.ToList(); + return list.Any(s => s.Notes.HasFlag(QuantityNotes.UndeclaredResult)) + ? null + : FromSources(list.Select(s => s.Kind), list.Select(s => (string?)s.Unit)); + } +} + +/// +/// What a meter's normalized amounts measure and in which unit (D-20). Every analysis series, rollup state, +/// unit check and tariff match speaks this unit — never the raw , which describes the +/// register or sensor and only belongs on the Readings tab. +/// +/// +/// +/// The unit follows from what the normalizer actually books, so this has to mirror the normalizers rather +/// than the meter's label: a runtime counter books its register's difference in the register's own time +/// unit (hours, minutes or seconds), unless its tank has a Fixed rate and it books register × litres per +/// hour; an instant-rate sensor books the rate integrated over hours (kW → kWh), and a rate over any other +/// time keeps its rate unit because the normalizer does not rescale it; a tank books volumes in the tank's +/// unit, because dipstick centimetres are calibrated to it. Getting this wrong is not cosmetic: a burner's +/// hours would be priced as litres, or a kW sensor added to a kWh register. +/// +/// +/// The kind follows the mode, except that a meter measures export, which is +/// never consumption (D-22). Units are returned in canonical form, so "m3" +/// and "m³" meters — and "Stk" and "stk" meters — carry the same string and may be compared ordinally. +/// +/// +public sealed record NormalizedQuantity(QuantityKind Kind, string Unit, QuantityNotes Notes = QuantityNotes.None) +{ + /// The unit of a Fixed-rate tank that has none; the tank's own default. + private const string DefaultTankUnit = "L"; + + /// + /// The provenance every amount of this quantity carries regardless of its readings: a fixed-rate runtime + /// conversion is (D-20); everything else adds nothing. + /// + public Provenance ImpliedProvenance => + Notes.HasFlag(QuantityNotes.FixedRateEstimate) ? Provenance.Estimated : Provenance.None; + + /// + /// The normalized quantity of a meter from its parts: the mode, its raw unit, its stored role token, its + /// tank (if any) and, for a virtual meter, its declared result. + /// + /// + /// For a virtual meter pass the stored, effective declaration (A-08). A legacy meter without one (D-28) is + /// evaluated as the implied sum of its links; pass + /// over those sources so the sum is labelled with what it adds up, not assumed to be consumption. + /// + public static NormalizedQuantity Of( + MeterMode mode, string? meterUnit, string? role, TankInfo? tank, DeclaredVirtualResult? virtualResult) + { + var unit = Units.Normalize(meterUnit); + var roleNote = MeterRoleRules.Parse(role) is { } stored && !MeterRoleRules.IsAllowed(stored, mode) + ? QuantityNotes.RoleIgnored + : QuantityNotes.None; + var flowKind = MeterRoleRules.Effective(mode, role) == MeterRole.GridExport + ? QuantityKind.Export + : QuantityKind.Consumption; + + var quantity = mode switch + { + MeterMode.CumulativeCounter or MeterMode.DirectDelta => new NormalizedQuantity(flowKind, unit), + MeterMode.InstantRate => InstantRate(flowKind, unit), + MeterMode.GenerationCounter => new NormalizedQuantity(QuantityKind.Generation, unit), + MeterMode.RuntimeCounter => Runtime(unit, tank), + MeterMode.ConsumableBalance => Consumable(unit, tank), + MeterMode.Virtual => Virtual(unit, virtualResult), + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown meter mode."), + }; + + return quantity with { Notes = quantity.Notes | roleNote }; + } + + /// The normalized quantity of a persisted meter, its tank (by meter id) and its declared result. + public static NormalizedQuantity Of(Meter meter, Tank? tank = null, DeclaredVirtualResult? virtualResult = null) + { + ArgumentNullException.ThrowIfNull(meter); + + return Of(meter.Mode, meter.Unit, MeterMeta.Role(meter.Meta), TankInfo.From(tank), virtualResult); + } + + /// What and in which unit, as D-20 writes it: var (kind, unit) = NormalizedQuantity.Of(…). + public void Deconstruct(out QuantityKind kind, out string unit) + { + kind = Kind; + unit = Unit; + } + + // The normalizer books value × elapsed hours. Power and "X/h" say that themselves; a rate over another + // time does not integrate to its quantity unit, so it keeps the rate unit; anything else is taken per hour + // as the normalizer takes it. A sensor without a unit has nothing to be uncertain about. + private static NormalizedQuantity InstantRate(QuantityKind kind, string unit) + { + if (unit.Length == 0 || Units.IsPerHourRate(unit)) + { + return new NormalizedQuantity(kind, Units.IntegratedOverHours(unit)); + } + + return Units.IsRate(unit) + ? new NormalizedQuantity(kind, unit, QuantityNotes.RateNotPerHour) + : new NormalizedQuantity(kind, unit, QuantityNotes.RateAssumedPerHour); + } + + // The runtime normalizer books the register's difference in the register's own unit, times the tank's + // fixed rate (per hour) or 1. A register labelled in hours, minutes or seconds therefore books that unit; + // one labelled with anything else counts operating hours by definition. + private static NormalizedQuantity Runtime(string meterUnit, TankInfo? tank) + { + var register = Units.Describe(meterUnit) is { Dimension: UnitDimension.Time } time ? time.Symbol : Units.Hour; + if (tank is not { ConvertsRuntime: true }) + { + return new NormalizedQuantity(QuantityKind.Runtime, register); + } + + var notes = QuantityNotes.FixedRateEstimate + | (register == Units.Hour ? QuantityNotes.None : QuantityNotes.RegisterNotInHours); + var tankUnit = Units.Normalize(tank.Unit); + return tankUnit.Length > 0 + ? new NormalizedQuantity(QuantityKind.Runtime, tankUnit, notes) + : new NormalizedQuantity(QuantityKind.Runtime, DefaultTankUnit, notes | QuantityNotes.NoTankUnit); + } + + // Levels are calibrated into the tank's unit, so usage is in that unit; without a tank row the meter's + // unit is all there is (the same fallback the event dialog uses). + private static NormalizedQuantity Consumable(string meterUnit, TankInfo? tank) + { + var tankUnit = Units.Normalize(tank?.Unit); + return tankUnit.Length > 0 + ? new NormalizedQuantity(QuantityKind.Consumption, tankUnit) + : new NormalizedQuantity(QuantityKind.Consumption, meterUnit, QuantityNotes.NoTankUnit); + } + + // A virtual meter is what its definition declares. Without a usable declaration the meter still needs a + // unit to be shown in, so its own unit and consumption are assumed and flagged. + private static NormalizedQuantity Virtual(string meterUnit, DeclaredVirtualResult? declared) + { + if (declared is not { HasAllowedKind: true }) + { + return new NormalizedQuantity(QuantityKind.Consumption, meterUnit, QuantityNotes.UndeclaredResult); + } + + var unit = Units.Normalize(declared.Unit); + return new NormalizedQuantity(declared.Kind, unit.Length > 0 ? unit : meterUnit); + } +} diff --git a/src/Core/Analysis/Quantities/TariffUnit.cs b/src/Core/Analysis/Quantities/TariffUnit.cs new file mode 100644 index 0000000..a7a8c5b --- /dev/null +++ b/src/Core/Analysis/Quantities/TariffUnit.cs @@ -0,0 +1,867 @@ +using System.Globalization; +using System.Text; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Quantities; + +/// What the denominator of a tariff unit turned out to be. +public enum TariffUnitBasis +{ + /// No currency, no separator, or a shape this parser does not know ("pauschal", "EUR"). + Unparseable, + + /// A price per recognised quantity unit ("EUR/kWh", "ct/kWh", "EUR/100 L"). + Quantity, + + /// A price per billing period ("EUR/month", "€/Jahr", "EUR/Quartal", "EUR/Tag"). + Period, + + /// A currency over a token this module does not know ("EUR/Stk"); it matches only itself. + OtherDenominator, + + /// + /// A price per a period this module recognises but cannot accrue ("EUR/2 Monate", "EUR/Woche", + /// "EUR/Halbjahr"). Taking it per month would be wrong by its own length, so it never applies. + /// + UnsupportedPeriod, +} + +/// The period a standing charge is quoted for (D-37, D-40). +public enum BillingPeriod +{ + Day, + Month, + + /// A calendar quarter of the local year (January–March, …). + Quarter, + + Year, +} + +/// How a tariff's unit relates to the meter it would price. +public enum TariffUnitFit +{ + /// The unit was understood and fits; converts it. + Applies, + + /// The unit was understood and does not fit: the cost is unavailable (unit) (D-37). + Mismatch, + + /// + /// The unit could not be checked. It is applied as before — at face value, or per month for a standing + /// charge — and the caller shows a warning (D-37). + /// + Unverified, + + /// Bonus, Discount and Tax are not applied yet (D-37, D-57); the tariff contributes nothing. + NotApplied, +} + +/// Detail behind a fit other than a clean . A code the UI localizes. +public enum TariffUnitIssue +{ + None, + + /// The unit is not "currency/denominator" in any form this parser knows. + Unparseable, + + /// The denominator is a token this module does not know and the meter's unit is not that token. + UnknownDenominator, + + /// The meter has no unit to compare with. + MeterUnitUnknown, + + /// A consumption price is quoted per period ("EUR/month" as a unit price). + PeriodForQuantity, + + /// A quantity price's unit does not convert into the meter's unit ("EUR/kWh" for m³). + IncompatibleUnit, + + /// A standing charge is quoted per quantity, not per period; per month was assumed. + QuantityForPeriod, + + /// The component is Bonus, Discount or Tax, which the cost engine does not apply. + ComponentNotApplied, + + /// + /// The tariff is quoted per a recognised unit, but the meter's unit is one this module does not know + /// ("Nm³", "kWh (el)"), so whether they match cannot be told; applied at face value with a warning. + /// + MeterUnitUnrecognised, + + /// The standing charge is quoted per a period that cannot be accrued ("EUR/2 Monate"). + UnsupportedPeriod, + + /// The tariff is quoted in another currency than the one expected ("USD/kWh" in an EUR instance). + CurrencyMismatch, +} + +/// +/// A parsed tariff unit: the currency it is quoted in, and what it is quoted per — a quantity +/// ( × ) or a . +/// +/// The unit as stored. +/// What the denominator is. +/// The canonical currency token ("EUR", "ct", "USD"), or null when none was recognised. +/// Major currency units per quoted unit: 1 for EUR, 0.01 for ct; 1 when unknown. +/// +/// The quantity unit (canonical when recognised), the period as written for an +/// , or null for a supported period or an unparseable unit. +/// +/// How many the price is for: 100 in "EUR/100 L", otherwise 1. +/// The billing period for a unit. +public sealed record ParsedTariffUnit( + string Raw, + TariffUnitBasis Basis, + string? Currency, + double CurrencyScale, + string? Denominator, + double DenominatorAmount, + BillingPeriod? Period) +{ + /// + /// What was set aside before parsing, for display: a VAT note ("brutto", "inkl. MwSt.") or bracketed text + /// ("(Grundversorgung)"). It never changes the price; null when there was none. + /// + public string? Qualifier { get; init; } + + /// True when both currency and denominator were recognised (a quantity or a period). + public bool IsRecognised => Basis is TariffUnitBasis.Quantity or TariffUnitBasis.Period or TariffUnitBasis.UnsupportedPeriod; + + /// + /// What the price is quoted per, for messages: "kWh", "100 L", "month", the period as written when it is + /// unsupported, or the raw unit when it could not be parsed. + /// + public string DenominatorText => Basis switch + { + TariffUnitBasis.Period => TariffUnit.PeriodToken(Period!.Value), + TariffUnitBasis.UnsupportedPeriod => Denominator!, + TariffUnitBasis.Quantity or TariffUnitBasis.OtherDenominator when DenominatorAmount == 1 => Denominator!, + TariffUnitBasis.Quantity or TariffUnitBasis.OtherDenominator => + $"{DenominatorAmount.ToString(CultureInfo.InvariantCulture)} {Denominator}", + _ => Raw, + }; + + /// + /// True when this unit has the shape needs — the first half of the tariff + /// editor's check on save (D-37): a unit or feed-in price is currency per quantity (a user's own unit such + /// as "EUR/Stk" included), a base price is currency per supported period. Bonus, Discount and Tax are not + /// applied, so any unit suits them. The second half is + /// against the units of the meters the tariff's scope prices and the instance currency, which is what + /// catches "EUR/kWh" on a water meter or "USD/kWh" in an EUR instance. + /// + public bool Suits(TariffComponent component) => component switch + { + TariffComponent.UnitPrice or TariffComponent.FeedIn => + Basis is TariffUnitBasis.Quantity or TariffUnitBasis.OtherDenominator, + TariffComponent.BasePrice => Basis == TariffUnitBasis.Period, + _ => true, + }; +} + +/// +/// Whether a tariff applies to a meter's normalized quantity and at which scale (D-37). +/// +/// The verdict. +/// +/// Multiply the tariff's value by this to get major currency per normalized meter unit (unit and feed-in +/// prices) or per (base prices). 0 for and +/// , so a careless caller adds nothing. +/// +/// The tariff's unit as stored. +/// What the tariff is quoted per ("kWh", "100 L", "month", or the raw unit). +/// The meter's normalized unit it was checked against. +/// Why the fit is not a clean , for the warning or explanation. +/// For a base price: the period it accrues over (Month when assumed). +public sealed record TariffApplicability( + TariffUnitFit Fit, + double Factor, + string TariffUnit, + string TariffDenominator, + string MeterUnit, + TariffUnitIssue Issue = TariffUnitIssue.None, + BillingPeriod? Period = null) +{ + /// True when the tariff is priced in (cleanly or with a warning). + public bool Applies => Fit is TariffUnitFit.Applies or TariffUnitFit.Unverified; + + /// True when the caller must show a warning next to the cost. + public bool NeedsWarning => Fit == TariffUnitFit.Unverified; + + /// The tariff value in major currency per meter unit (or per period); 0 unless it applies. + public double Convert(double tariffValue) => tariffValue * Factor; +} + +/// +/// How a standing charge accrues per local day (D-40): a monthly charge is spread over the days of each +/// local month, a quarterly one over the days of the local calendar quarter, a yearly one over the days of +/// the local year, a daily one is charged as is. Summed over a whole period the days give back exactly the +/// quoted value, whatever the period's length. +/// +/// The period the charge is quoted for. +/// Major currency per quoted currency unit (0.01 for a charge in ct); 0 when it does not apply. +/// True when the unit could not be read and per month was assumed (the old behaviour). +/// Why the period was assumed, or why the charge does not apply. +public sealed record BasePriceAccrual(BillingPeriod Period, double CurrencyScale, bool Assumed, TariffUnitIssue Issue) +{ + /// + /// The verdict behind the accrual. (an unsupported period, another + /// currency) accrues nothing, because is 0; the cost is unavailable. + /// + public TariffUnitFit Fit { get; init; } = Assumed ? TariffUnitFit.Unverified : TariffUnitFit.Applies; + + /// True when the charge is accrued (cleanly or with a warning). + public bool Applies => Fit is TariffUnitFit.Applies or TariffUnitFit.Unverified; + + /// + /// The charge for one local day of a month with days in a year of + /// days, in major currency. A quarterly charge needs the quarter's length, + /// which these two cannot give: use for it. + /// + /// The charge is quarterly. + public double PerDay(double value, int daysInMonth, int daysInYear) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(daysInMonth); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(daysInYear); + + var scaled = value * CurrencyScale; + return Period switch + { + BillingPeriod.Day => scaled, + BillingPeriod.Month => scaled / daysInMonth, + BillingPeriod.Year => scaled / daysInYear, + BillingPeriod.Quarter => throw new InvalidOperationException( + "A quarterly charge accrues over the days of its quarter; use PerDay(value, localDay)."), + _ => throw new InvalidOperationException($"Unknown billing period {Period}."), + }; + } + + /// The charge for the local calendar day , in major currency. + public double PerDay(double value, DateOnly localDay) => value * CurrencyScale / DaysInPeriod(localDay); + + /// + /// How many local days the period that contains has: 1, the month's length, + /// the calendar quarter's (90–92) or the year's (365/366). + /// + public int DaysInPeriod(DateOnly localDay) => Period switch + { + BillingPeriod.Day => 1, + BillingPeriod.Month => DateTime.DaysInMonth(localDay.Year, localDay.Month), + BillingPeriod.Quarter => Enumerable.Range((localDay.Month - 1) / 3 * 3 + 1, 3) + .Sum(month => DateTime.DaysInMonth(localDay.Year, month)), + BillingPeriod.Year => DateTime.IsLeapYear(localDay.Year) ? 366 : 365, + _ => throw new InvalidOperationException($"Unknown billing period {Period}."), + }; +} + +/// +/// Reads the free-text ("EUR/kWh", "ct/kWh", "EUR/100L", "EUR/Monat") and decides +/// whether a tariff can price a meter (D-37). +/// +/// +/// +/// A tariff used to be applied to whatever the meter booked, so a global "EUR/kWh" price also priced water in +/// m³ and burner hours, and "EUR/100L" was charged per litre. A price now applies only when its denominator +/// converts into the meter's normalized unit; known scales are converted +/// (ct → EUR, per 100 L, per MWh, m³ ↔ L). A unit that is understood and does not fit is a +/// : the cost is unavailable, because any number would be wrong. A unit +/// that cannot be understood — on either side — keeps the old behaviour with a warning: refusing it would +/// silently drop costs existing installs have always seen. +/// +/// +/// What a bill writes next to the unit is not part of it: "brutto", "inkl. MwSt." or "(Grundversorgung)" are +/// set aside before parsing (), so "€/Jahr inkl. MwSt." is a yearly +/// charge, not an unreadable one taken per month. +/// +/// +/// Standing charges are quoted per day, month, quarter or year ("EUR/12 Monate" is a year). An unreadable +/// standing-charge unit is taken as per month, which is what the cost engine always assumed; a period that is +/// read but cannot be accrued ("EUR/2 Monate") is a mismatch instead, because per month would be wrong by its +/// length. +/// +/// +/// Given the currency the instance bills in (D-43), a unit quoted in another currency is a mismatch, and a +/// minor unit counts only under its own major currency (ct under EUR, USD or CHF; p under GBP; Rp under CHF). +/// Without it the currency is not checked, as before. +/// +/// +public static class TariffUnit +{ + private static readonly string[] WordSeparators = ["per", "pro", "je"]; + + /// Words that start a VAT or pricing note after the unit ("brutto", "inkl. MwSt.", "zzgl. 19 % USt"). + private static readonly HashSet QualifierWords = new(StringComparer.OrdinalIgnoreCase) + { + "brutto", "netto", "gross", "net", + "inkl", "incl", "inklusive", "inclusive", "including", "mit", + "zzgl", "zuzügl", "zuzüglich", "zuzueglich", "plus", + "exkl", "excl", "exklusive", "exclusive", "excluding", "ohne", + "mwst", "ust", "vat", "tax", + }; + + private static readonly string[] CentMajors = ["EUR", "USD", "CAD", "AUD", "NZD", "CHF"]; + + private static readonly IReadOnlyList CurrencyTable = BuildCurrencies(); + + private static readonly Dictionary CurrencyAliases = + CurrencyTable.ToDictionary(c => c.Alias, StringComparer.OrdinalIgnoreCase); + + private static readonly IReadOnlyList PeriodTable = BuildPeriods(); + + private static readonly Dictionary PeriodAliases = + PeriodTable.ToDictionary(p => p.Alias, StringComparer.OrdinalIgnoreCase); + + /// Every currency spelling the parser recognises; exposed so a test can round-trip each entry. + internal static IReadOnlyList CurrencyAliasTable => CurrencyTable; + + /// Every period spelling the parser recognises; exposed so a test can round-trip each entry. + internal static IReadOnlyList PeriodAliasTable => PeriodTable; + + /// + /// Parses a tariff unit. Accepted shapes: currency/denominator, currency per|pro|je + /// denominator, currency p.a. and currency monthly|monatlich|quartalsweise|…, each + /// optionally followed by a VAT note or with bracketed text, which are set aside. The denominator is a + /// period (day/Tag/d, month/Monat/mo, quarter/Quartal, year/Jahr/a, or a whole number of them: "12 Monate" + /// is a year) or an optional whole amount and a unit ("100 L", "100L", "1.000 L", "MWh"). Currencies: + /// EUR/€/Euro, ct/cent (0.01), USD/$, GBP/£, p (0.01), CHF/Fr., Rp (0.01), and any other three-letter + /// upper-case code. + /// + public static ParsedTariffUnit Parse(string? tariffUnit) + { + var raw = tariffUnit?.Trim() ?? string.Empty; + var core = StripQualifiers(raw, out var qualifier); + return ParseCore(raw, core) with { Qualifier = qualifier }; + } + + /// + /// Whether a tariff with prices a meter whose normalized unit is + /// as (D-37), without checking the currency: + /// + /// Unit and feed-in prices apply when the denominator converts into the meter unit, with the factor + /// that turns the value into major currency per meter unit ("ct/kWh" → 0.01, "EUR/100L" for L → 0.01, + /// "EUR/MWh" for kWh → 0.001). A recognised unit that does not convert into a recognised meter unit — + /// including a per-period unit, and a meter that books a rate ("L/min") — is a mismatch. An unrecognised + /// tariff unit, or a meter unit this module does not know, applies unverified at the currency scale. + /// Base prices apply per ; an unrecognised or per-quantity unit applies + /// unverified, per month; a recognised period that cannot be accrued is a mismatch. + /// Bonus, Discount and Tax are . + /// + /// + public static TariffApplicability Applicability(string? tariffUnit, string? meterUnit, TariffComponent component) => + Check(Parse(tariffUnit), meterUnit, component, expectedCurrency: null); + + /// + /// As , and a unit quoted in another currency + /// than (the instance currency, D-43: "EUR", "€", "CHF") is a + /// . A unit without a recognised currency cannot be checked. + /// + public static TariffApplicability Applicability( + string? tariffUnit, string? meterUnit, TariffComponent component, string expectedCurrency) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expectedCurrency); + + return Check(Parse(tariffUnit), meterUnit, component, expectedCurrency); + } + + /// + /// As for a unit parsed once, so a cost + /// engine checking every meter × month × component does not reparse the free text each time. + /// + public static TariffApplicability Applicability(ParsedTariffUnit tariffUnit, string? meterUnit, TariffComponent component) + { + ArgumentNullException.ThrowIfNull(tariffUnit); + + return Check(tariffUnit, meterUnit, component, expectedCurrency: null); + } + + /// + /// As for a unit parsed once. + /// + public static TariffApplicability Applicability( + ParsedTariffUnit tariffUnit, string? meterUnit, TariffComponent component, string expectedCurrency) + { + ArgumentNullException.ThrowIfNull(tariffUnit); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedCurrency); + + return Check(tariffUnit, meterUnit, component, expectedCurrency); + } + + /// + /// How a standing charge with accrues per local day (D-37, D-40); an + /// unrecognised unit accrues per month and is flagged , and an + /// unsupported period accrues nothing (). + /// + public static BasePriceAccrual BaseAccrual(string? tariffUnit) => Accrual(Parse(tariffUnit), expectedCurrency: null); + + /// + /// As ; a charge quoted in another currency than + /// accrues nothing (). + /// + public static BasePriceAccrual BaseAccrual(string? tariffUnit, string expectedCurrency) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expectedCurrency); + + return Accrual(Parse(tariffUnit), expectedCurrency); + } + + /// As for a unit parsed once. + public static BasePriceAccrual BaseAccrual(ParsedTariffUnit tariffUnit) + { + ArgumentNullException.ThrowIfNull(tariffUnit); + + return Accrual(tariffUnit, expectedCurrency: null); + } + + /// As for a unit parsed once. + public static BasePriceAccrual BaseAccrual(ParsedTariffUnit tariffUnit, string expectedCurrency) + { + ArgumentNullException.ThrowIfNull(tariffUnit); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedCurrency); + + return Accrual(tariffUnit, expectedCurrency); + } + + /// + /// The unit the tariff editor pre-fills for (D-52): currency per the meter's + /// normalized unit for unit and feed-in prices, currency per month for a base price, the currency alone + /// otherwise. With no meter unit a unit price falls back to the currency alone. + /// + public static string Suggest(TariffComponent component, string? meterUnit, string currency = "EUR") + { + ArgumentException.ThrowIfNullOrWhiteSpace(currency); + + var unit = Units.Normalize(meterUnit); + return component switch + { + TariffComponent.UnitPrice or TariffComponent.FeedIn when unit.Length > 0 => $"{currency}/{unit}", + TariffComponent.BasePrice => $"{currency}/{PeriodToken(BillingPeriod.Month)}", + _ => currency, + }; + } + + /// The English token written for a period in a unit ("day", "month", "quarter", "year"). + public static string PeriodToken(BillingPeriod period) => period switch + { + BillingPeriod.Day => "day", + BillingPeriod.Month => "month", + BillingPeriod.Quarter => "quarter", + BillingPeriod.Year => "year", + _ => throw new ArgumentOutOfRangeException(nameof(period), period, "Unknown billing period."), + }; + + /// + /// True when a unit quoted in (a canonical token from + /// ) is priced in : the same major + /// currency, or a minor unit of it. + /// + internal static bool IsQuotedIn(string currency, string expectedCurrency) + { + var expected = CanonicalMajor(expectedCurrency); + return CurrencyAliases.TryGetValue(currency, out var known) + ? known.Majors.Contains(expected, StringComparer.Ordinal) + : string.Equals(currency, expected, StringComparison.OrdinalIgnoreCase); + } + + private static TariffApplicability Check( + ParsedTariffUnit parsed, string? meterUnit, TariffComponent component, string? expectedCurrency) + { + var meter = Units.Normalize(meterUnit); + var raw = parsed.Raw; + var denominator = parsed.DenominatorText; + + if (component is not (TariffComponent.UnitPrice or TariffComponent.FeedIn or TariffComponent.BasePrice)) + { + return new TariffApplicability( + TariffUnitFit.NotApplied, 0, raw, denominator, meter, TariffUnitIssue.ComponentNotApplied); + } + + // A price in another currency is wrong by an exchange rate this module does not know. + if (expectedCurrency is not null && parsed.Currency is { } currency && !IsQuotedIn(currency, expectedCurrency)) + { + return new TariffApplicability( + TariffUnitFit.Mismatch, + 0, + raw, + denominator, + meter, + TariffUnitIssue.CurrencyMismatch, + component == TariffComponent.BasePrice ? parsed.Period : null); + } + + return component == TariffComponent.BasePrice + ? BaseApplicability(parsed, meter) + : QuantityApplicability(parsed, meter); + } + + private static TariffApplicability BaseApplicability(ParsedTariffUnit parsed, string meter) + { + var raw = parsed.Raw; + var denominator = parsed.DenominatorText; + switch (parsed.Basis) + { + case TariffUnitBasis.Period: + return new TariffApplicability( + TariffUnitFit.Applies, parsed.CurrencyScale, raw, denominator, meter, Period: parsed.Period); + + case TariffUnitBasis.UnsupportedPeriod: + return new TariffApplicability( + TariffUnitFit.Mismatch, 0, raw, denominator, meter, TariffUnitIssue.UnsupportedPeriod); + + default: + // The cost engine always charged a standing charge per month; keep that for a unit it cannot read. + var issue = parsed.Basis == TariffUnitBasis.Quantity + ? TariffUnitIssue.QuantityForPeriod + : TariffUnitIssue.Unparseable; + return new TariffApplicability( + TariffUnitFit.Unverified, parsed.CurrencyScale, raw, denominator, meter, issue, BillingPeriod.Month); + } + } + + private static TariffApplicability QuantityApplicability(ParsedTariffUnit parsed, string meter) + { + var raw = parsed.Raw; + var denominator = parsed.DenominatorText; + + TariffApplicability Unverified(TariffUnitIssue issue, double factor) => + new(TariffUnitFit.Unverified, factor, raw, denominator, meter, issue); + + TariffApplicability Mismatch(TariffUnitIssue issue) => + new(TariffUnitFit.Mismatch, 0, raw, denominator, meter, issue); + + switch (parsed.Basis) + { + case TariffUnitBasis.Unparseable: + return Unverified(TariffUnitIssue.Unparseable, parsed.CurrencyScale); + + case TariffUnitBasis.Period or TariffUnitBasis.UnsupportedPeriod: + return Mismatch(TariffUnitIssue.PeriodForQuantity); + } + + var perQuotedUnit = parsed.CurrencyScale / parsed.DenominatorAmount; + if (meter.Length == 0) + { + return Unverified(TariffUnitIssue.MeterUnitUnknown, perQuotedUnit); + } + + // Price per meter unit = price per denominator unit × (denominator units in one meter unit). + if (Units.ConversionFactor(meter, parsed.Denominator) is { } denominatorsPerMeterUnit) + { + return new TariffApplicability( + TariffUnitFit.Applies, perQuotedUnit * denominatorsPerMeterUnit, raw, denominator, meter); + } + + if (parsed.Basis == TariffUnitBasis.OtherDenominator) + { + return Unverified(TariffUnitIssue.UnknownDenominator, perQuotedUnit); + } + + // The price is per a recognised unit. A recognised meter unit (or a rate, which is never a quantity) + // proves the mismatch; one this module does not know only means it cannot tell. + return Units.Describe(meter) is not null || Units.IsRate(meter) + ? Mismatch(TariffUnitIssue.IncompatibleUnit) + : Unverified(TariffUnitIssue.MeterUnitUnrecognised, perQuotedUnit); + } + + private static BasePriceAccrual Accrual(ParsedTariffUnit parsed, string? expectedCurrency) + { + var applicability = Check(parsed, null, TariffComponent.BasePrice, expectedCurrency); + return new BasePriceAccrual( + applicability.Period ?? BillingPeriod.Month, + applicability.Factor, + applicability.Fit == TariffUnitFit.Unverified, + applicability.Issue) + { + Fit = applicability.Fit, + }; + } + + private static ParsedTariffUnit ParseCore(string raw, string core) + { + if (!TrySplit(core, out var numerator, out var denominator) + || !TryCurrency(numerator, out var currency, out var currencyScale)) + { + var knownCurrency = TryCurrency(core, out var bareCurrency, out var bareScale); + return Unparseable(raw, knownCurrency ? bareCurrency : null, knownCurrency ? bareScale : 1); + } + + if (PeriodAliases.TryGetValue(denominator, out var period)) + { + return FromPeriod(raw, currency, currencyScale, period, 1, denominator); + } + + if (!TrySplitAmount(denominator, out var amount, out var unit)) + { + return Unparseable(raw, currency, currencyScale); + } + + // "EUR/1 Monat" is a month, "EUR/12 Monate" a year and "EUR/3 Monate" a quarter. + if (PeriodAliases.TryGetValue(unit, out var countedPeriod)) + { + return FromPeriod(raw, currency, currencyScale, countedPeriod, amount, denominator); + } + + return Units.Describe(unit) is { } info + ? new ParsedTariffUnit(raw, TariffUnitBasis.Quantity, currency, currencyScale, info.Symbol, amount, null) + : new ParsedTariffUnit(raw, TariffUnitBasis.OtherDenominator, currency, currencyScale, unit, amount, null); + } + + private static ParsedTariffUnit FromPeriod( + string raw, string currency, double currencyScale, PeriodAlias alias, double amount, string written) => + Resolve(alias, amount) is { } period + ? new ParsedTariffUnit(raw, TariffUnitBasis.Period, currency, currencyScale, null, 1, period) + : new ParsedTariffUnit(raw, TariffUnitBasis.UnsupportedPeriod, currency, currencyScale, written, 1, null); + + // Only lengths the accrual can spread exactly over local calendar days: a day, a month, a calendar + // quarter, a year. "2 Monate" or a week has no calendar anchor, so it is recognised but not supported. + private static BillingPeriod? Resolve(PeriodAlias alias, double amount) + { + var count = alias.Count * amount; + return alias.Unit switch + { + PeriodUnit.Day when count == 1 => BillingPeriod.Day, + PeriodUnit.Month when count == 1 => BillingPeriod.Month, + PeriodUnit.Month when count == 3 => BillingPeriod.Quarter, + PeriodUnit.Month when count == 12 => BillingPeriod.Year, + _ => null, + }; + } + + private static ParsedTariffUnit Unparseable(string raw, string? currency, double currencyScale) => + new(raw, TariffUnitBasis.Unparseable, currency, currencyScale, null, 1, null); + + // "€/kWh brutto", "€/Jahr inkl. MwSt.", "EUR/kWh (Grundversorgung)": what follows the unit describes the + // price, not what it is quoted per. Bracketed text goes wherever it stands; a note is cut from the first + // note word after the unit's first word to the end. + private static string StripQualifiers(string raw, out string? qualifier) + { + qualifier = null; + if (raw.Length == 0) + { + return raw; + } + + var removed = new List(); + var kept = new StringBuilder(raw.Length); + var bracket = new StringBuilder(); + var depth = 0; + foreach (var c in raw) + { + if (c is '(' or '[') + { + depth++; + bracket.Append(c); + } + else if (depth > 0) + { + bracket.Append(c); + if (c is ')' or ']' && --depth == 0) + { + removed.Add(bracket.ToString(1, bracket.Length - 2).Trim()); + bracket.Clear(); + } + } + else + { + kept.Append(c); + } + } + + // An unbalanced bracket is not a note; leave it where it was. + kept.Append(bracket); + + var words = kept.ToString().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + var cut = words.Length > 1 + ? Array.FindIndex(words, 1, w => QualifierWords.Contains(w.Trim('.', ',', ';', ':'))) + : -1; + if (cut > 0) + { + removed.Insert(0, string.Join(' ', words[cut..])); + words = words[..cut]; + } + + if (removed.Count == 0) + { + return raw; + } + + qualifier = string.Join(' ', removed.Where(r => r.Length > 0)); + if (qualifier.Length == 0) + { + qualifier = null; + } + + return string.Join(' ', words).TrimEnd(',', ';', ':', '-', ' '); + } + + // "EUR/kWh", "EUR / 100 L", "EUR per kWh", "€ pro Monat", "EUR p.a.", "EUR monatlich". + private static bool TrySplit(string raw, out string numerator, out string denominator) + { + numerator = string.Empty; + denominator = string.Empty; + if (raw.Length == 0) + { + return false; + } + + if (raw.Contains('/', StringComparison.Ordinal)) + { + return Units.TrySplitRate(raw, out numerator, out denominator); + } + + var words = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (words.Length >= 3 && WordSeparators.Contains(words[1], StringComparer.OrdinalIgnoreCase)) + { + numerator = words[0]; + denominator = string.Join(' ', words[2..]); + return true; + } + + if (words.Length == 2 && PeriodAliases.ContainsKey(words[1])) + { + numerator = words[0]; + denominator = words[1]; + return true; + } + + return false; + } + + // "100 L", "100L", "1.000 L" (German thousands), "MWh". The amount must be a positive whole number. + private static bool TrySplitAmount(string denominator, out double amount, out string unit) + { + amount = 1; + var digits = 0; + while (digits < denominator.Length && (char.IsAsciiDigit(denominator[digits]) || denominator[digits] == '.')) + { + digits++; + } + + unit = denominator[digits..].Trim(); + if (unit.Length == 0) + { + return false; + } + + if (digits == 0) + { + return true; + } + + // "1,5 L" is a decimal amount no tariff is quoted per; do not read it as 1 of a unit called ",5 L". + if (!char.IsLetter(unit[0])) + { + return false; + } + + var number = denominator[..digits]; + var groups = number.Split('.'); + if (groups[0].Length == 0 + || (groups.Length > 1 && (groups[0].Length > 3 || groups.Skip(1).Any(g => g.Length != 3)))) + { + return false; + } + + amount = double.Parse(string.Concat(groups), NumberStyles.None, CultureInfo.InvariantCulture); + return amount > 0; + } + + private static bool TryCurrency(string token, out string code, out double scale) + { + code = string.Empty; + scale = 1; + var trimmed = token.Trim(); + if (CurrencyAliases.TryGetValue(trimmed, out var known)) + { + (code, scale) = (known.Code, known.Scale); + return true; + } + + // Any other ISO 4217 code (SEK, PLN, …) is a major unit. Unit symbols of three letters are not currencies. + if (trimmed.Length == 3 && trimmed.All(char.IsAsciiLetterUpper) && Units.Describe(trimmed) is null) + { + code = trimmed; + return true; + } + + return false; + } + + // The instance currency as a major code: "€" and "eur" are EUR; an ISO code the table does not know is + // taken as written, upper-cased. + private static string CanonicalMajor(string currency) + { + var trimmed = currency.Trim(); + return CurrencyAliases.TryGetValue(trimmed, out var known) && known.Scale == 1 + ? known.Code + : trimmed.ToUpperInvariant(); + } + + private static List BuildCurrencies() + { + var table = new List(); + + void Add(string code, double scale, string[] majors, params string[] aliases) + { + table.Add(new CurrencyAlias(code, code, scale, majors)); + table.AddRange(aliases.Select(alias => new CurrencyAlias(alias, code, scale, majors))); + } + + Add("EUR", 1, ["EUR"], "€", "Euro", "Euros"); + + // A cent is the minor unit of the euro and the dollars, and (as centime) of the Swiss franc. + Add("ct", 0.01, CentMajors, "ct.", "cent", "cents", "€ct", "eurocent", "eurocents", "c"); + Add("USD", 1, ["USD"], "$"); + Add("GBP", 1, ["GBP"], "£"); + Add("p", 0.01, ["GBP"], "pence"); + Add("CHF", 1, ["CHF"], "Fr.", "SFr."); + Add("Rp", 0.01, ["CHF"], "Rp.", "Rappen"); + return table; + } + + private static List BuildPeriods() + { + var table = new List(); + + void Add(PeriodUnit unit, int count, params string[] aliases) => + table.AddRange(aliases.Select(alias => new PeriodAlias(alias, unit, count))); + + Add(PeriodUnit.Day, 1, "d", "day", "days", "Tag", "Tage", "Tagen", "täglich", "taeglich", "daily"); + Add(PeriodUnit.Week, 1, "week", "weeks", "wk", "Woche", "Wochen", "wöchentlich", "woechentlich", "weekly"); + Add( + PeriodUnit.Month, + 1, + "month", "months", "mo", "mon", "mon.", "Monat", "Monate", "Monaten", "monatlich", "mtl", "mtl.", "monthly"); + Add( + PeriodUnit.Month, + 3, + "quarter", "quarters", "quarterly", "Quartal", "Quartale", "Quartalen", "quartalsweise", "Vierteljahr", + "vierteljährlich", "vierteljaehrlich"); + Add( + PeriodUnit.Month, + 6, + "Halbjahr", "halbjährlich", "halbjaehrlich", "half-year", "half-yearly", "semiannual", "semiannually"); + Add( + PeriodUnit.Month, + 12, + "a", "year", "years", "yr", "y", "Jahr", "Jahre", "Jahren", "jährlich", "jaehrlich", "yearly", "annual", + "annually", "p.a."); + return table; + } +} + +/// The calendar unit a period spelling counts in. +internal enum PeriodUnit +{ + Day, + Week, + Month, +} + +/// One spelling of a billing period: × ("Quartal" = 3 months). +internal sealed record PeriodAlias(string Alias, PeriodUnit Unit, int Count); + +/// +/// One spelling of a currency: its canonical , how much of a major unit it is, and the major +/// currencies it may be quoted under (a major currency only under itself). +/// +internal sealed record CurrencyAlias(string Alias, string Code, double Scale, IReadOnlyList Majors); diff --git a/src/Core/Analysis/Quantities/Units.cs b/src/Core/Analysis/Quantities/Units.cs new file mode 100644 index 0000000..76a6a4f --- /dev/null +++ b/src/Core/Analysis/Quantities/Units.cs @@ -0,0 +1,368 @@ +namespace MeterVault.Core.Analysis.Quantities; + +/// What a unit measures. Units of one dimension convert into each other by a fixed scale. +public enum UnitDimension +{ + /// A token this module does not recognise (e.g. "Stk"). It only ever matches itself. + Unknown, + + Energy, + Power, + Volume, + Mass, + Time, +} + +/// +/// A recognised unit: its canonical , its , and the size of one +/// unit expressed in the dimension's reference unit (): kWh for energy, kW for power, +/// L for volume, kg for mass and h for time. +/// +public sealed record UnitInfo(string Symbol, UnitDimension Dimension, double Scale); + +/// +/// Canonical spelling and scale of the units meters and tariffs are written in (D-20, D-37). The only unit +/// normalizer in the analysis layer (A-09): every module compares units through , +/// or . +/// +/// +/// +/// and are free text. The seed writes "m3", +/// the editor suggests whatever the energy type's base unit says, and a user may type "m³", "cbm", "Liter", +/// "KWH" or "Kilowattstunden". Analysis compares units across meters (virtual formulas, per-type totals) and +/// between a tariff and the meter it prices, so every one of those comparisons first runs through +/// : equal quantities must compare equal however they were typed, or a water tariff in +/// "EUR/m3" would refuse a meter in "m³". +/// +/// +/// Only spellings with one unambiguous meaning are aliased, and case is ignored except where it is the only +/// thing that tells two units apart. That happens exactly once in metering: a leading "M" is mega, a leading +/// "m" is milli. Home Assistant reports small sensors in mW and mWh, so "mW" and "mWh" are milliwatts, "MW" +/// and "MWh" (and "MWH", "Mwh") are megawatts; a token whose only difference from a mega symbol is a +/// lower-case "m" ("mwh", "mw", "mj") could be either, a factor of 10⁹ apart, and is left unrecognised +/// rather than guessed. +/// +/// +/// A token that is not recognised has no canonical spelling of its own, so it is folded to lower case (runs +/// of whitespace collapsed): "Stk", "STK" and "stk" all normalize to "stk". That makes the normalized string a +/// key that can be compared ordinally, grouped with a default dictionary and stored in +/// meter_rollup_state.normalized_unit without ever splitting one unit into two. The raw spelling stays +/// on the meter for the Readings tab. +/// +/// +public static class Units +{ + // Canonical symbols. Everything else in this module refers to these. + public const string MilliwattHour = "mWh"; + public const string WattHour = "Wh"; + public const string KilowattHour = "kWh"; + public const string MegawattHour = "MWh"; + public const string GigawattHour = "GWh"; + public const string Megajoule = "MJ"; + public const string Gigajoule = "GJ"; + public const string Milliwatt = "mW"; + public const string Watt = "W"; + public const string Kilowatt = "kW"; + public const string Megawatt = "MW"; + public const string Gigawatt = "GW"; + public const string Litre = "L"; + public const string Hectolitre = "hL"; + public const string CubicMetre = "m³"; + public const string Gram = "g"; + public const string Kilogram = "kg"; + public const string Tonne = "t"; + public const string Hour = "h"; + public const string Minute = "min"; + public const string Second = "s"; + + private static readonly IReadOnlyList Table = BuildTable(); + + /// Every alias exactly as written, the case-sensitive milli symbols included. + private static readonly Dictionary ExactAliases = + Table.ToDictionary(a => a.Alias, a => a.Info, StringComparer.Ordinal); + + /// + /// Every alias whose case carries no meaning. The milli symbols are left out: case-folded, "mW" would + /// collide with "MW", which is exactly the confusion the exact lookup exists to prevent. + /// + private static readonly Dictionary FoldedAliases = + Table.Where(a => !a.CaseSensitive).ToDictionary(a => a.Alias, a => a.Info, StringComparer.OrdinalIgnoreCase); + + /// Power units and the energy unit one hour of them integrates to (instant-rate meters, D-20). + private static readonly Dictionary PowerToEnergy = new(StringComparer.Ordinal) + { + [Milliwatt] = MilliwattHour, + [Watt] = WattHour, + [Kilowatt] = KilowattHour, + [Megawatt] = MegawattHour, + [Gigawatt] = GigawattHour, + }; + + /// + /// Compares units the way does ("m3" = "m³", "Stk" = "stk", null = blank). Use it for + /// every dictionary, grouping or distinct over units that did not come out of ; + /// normalized units may also be compared ordinally. + /// + public static IEqualityComparer Comparer { get; } = new UnitComparer(); + + /// + /// Every alias the module recognises, with the unit it names and whether its case matters. Exposed so a + /// test can prove each entry normalizes to its own symbol; nothing else should depend on the table. + /// + internal static IReadOnlyList AliasTable => Table; + + /// + /// The canonical spelling of : a recognised alias becomes its symbol ("m3", "cbm" + /// → "m³"; "l", "Liter" → "L"; "KWH", "Kilowattstunden" → "kWh"; "Std" → "h"), a rate "a/b" normalizes + /// both sides ("m3/h" → "m³/h"), and anything else is trimmed, its whitespace collapsed and folded to lower + /// case ("Stk" → "stk"), so that every spelling of it gives one key. Null or blank gives "". + /// + public static string Normalize(string? unit) + { + if (string.IsNullOrWhiteSpace(unit)) + { + return string.Empty; + } + + var trimmed = unit.Trim(); + if (Describe(trimmed) is { } info) + { + return info.Symbol; + } + + return TrySplitRate(trimmed, out var numerator, out var denominator) + ? $"{Normalize(numerator)}/{Normalize(denominator)}" + : string.Join(' ', trimmed.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant(); + } + + /// + /// The recognised unit behind , or null for an unknown token, a blank string, a rate + /// ("L/h") or a milli/mega spelling that case cannot settle ("mwh"). Whitespace inside the token is ignored + /// ("k Wh", "cubic metre"). + /// + public static UnitInfo? Describe(string? unit) + { + if (string.IsNullOrWhiteSpace(unit)) + { + return null; + } + + var key = string.Concat(unit.Where(c => !char.IsWhiteSpace(c))); + if (ExactAliases.TryGetValue(key, out var exact)) + { + return exact; + } + + if (!FoldedAliases.TryGetValue(key, out var folded)) + { + return null; + } + + // "mwh" differs from "MWh" only by case, and a lower-case m is how milli is written: not mega. + var isMegaSymbolWithMilliCase = key[0] == 'm' + && folded.Symbol[0] == 'M' + && string.Equals(key, folded.Symbol, StringComparison.OrdinalIgnoreCase); + return isMegaSymbolWithMilliCase ? null : folded; + } + + /// + /// True when both spell the same unit after ("m3" and "m³"; "Stk" and "stk"). Units + /// that merely convert into each other (kWh and MWh) are compatible, not the same. + /// + public static bool AreSame(string? a, string? b) => + string.Equals(Normalize(a), Normalize(b), StringComparison.Ordinal); + + /// + /// True when a quantity in can be expressed in by a fixed scale: + /// the same unit, two recognised units of one dimension (Wh/kWh/MWh, L/hL/m³), or two rates whose + /// numerators and denominators are each compatible. A blank unit is compatible with nothing. + /// + public static bool AreCompatible(string? a, string? b) => ConversionFactor(a, b) is not null; + + /// + /// The factor that turns an amount in into the same amount in + /// (MWh → kWh = 1000; L → m³ = 0.001; "m³/h" → "L/h" = 1000), or null when the units are not + /// compatible. Identical tokens, recognised or not, convert at 1. + /// + public static double? ConversionFactor(string? from, string? to) + { + if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to)) + { + return null; + } + + var fromInfo = Describe(from); + var toInfo = Describe(to); + if (fromInfo is not null && toInfo is not null) + { + return fromInfo.Dimension == toInfo.Dimension ? fromInfo.Scale / toInfo.Scale : null; + } + + if (fromInfo is null && toInfo is null + && TrySplitRate(from.Trim(), out var fromNumerator, out var fromDenominator) + && TrySplitRate(to.Trim(), out var toNumerator, out var toDenominator) + && ConversionFactor(fromNumerator, toNumerator) is { } numeratorFactor + && ConversionFactor(fromDenominator, toDenominator) is { } denominatorFactor) + { + return numeratorFactor / denominatorFactor; + } + + // One side recognised and the other not can never match; two unknown tokens match only themselves. + return fromInfo is null && toInfo is null && AreSame(from, to) ? 1d : null; + } + + /// True for mW, W, kW, MW and GW. + public static bool IsPower(string? unit) => Describe(unit)?.Dimension == UnitDimension.Power; + + /// + /// True when is unmistakably a rate per hour: a power unit (energy per hour), + /// power per something ("W/m²", energy per hour per m²) or an explicit "…/h". The instant-rate normalizer + /// integrates every value as a rate per hour of the meter's own unit, so for any other unit ("L/min", + /// "kWh", "Stk") that reading is not what the unit says. + /// + public static bool IsPerHourRate(string? rateUnit) + { + if (IsPower(rateUnit)) + { + return true; + } + + return !string.IsNullOrWhiteSpace(rateUnit) + && TrySplitRate(rateUnit.Trim(), out var numerator, out var denominator) + && (Describe(denominator)?.Symbol == Hour || IsPower(numerator)); + } + + /// + /// The unit an instant-rate meter's value integrates to over hours (D-20): power becomes energy (W → Wh, + /// kW → kWh, and "W/m²" → "Wh/m²"), a rate per hour "X/h" becomes X, and every other unit is returned + /// normalized but unchanged. + /// + /// + /// Only an hour denominator integrates, because the normalizer books value × elapsed hours and never + /// rescales: a flow in "L/min" books 1/60 of the litres that ran, and "m³/s" 1/3600. Calling those amounts + /// "L" or "m³" would let a price per litre apply without a warning to a number 60× too small. Keeping the + /// rate unit ("L/min") instead makes every tariff and total refuse it, which is the honest answer until the + /// source carries a scale that converts it to a rate per hour. + /// + public static string IntegratedOverHours(string? rateUnit) + { + var normalized = Normalize(rateUnit); + if (PowerToEnergy.TryGetValue(normalized, out var energy)) + { + return energy; + } + + if (!TrySplitRate(normalized, out var numerator, out var denominator)) + { + return normalized; + } + + if (Describe(denominator)?.Symbol == Hour) + { + return numerator; + } + + return PowerToEnergy.TryGetValue(numerator, out var energyPer) ? $"{energyPer}/{denominator}" : normalized; + } + + /// True when has the shape of a rate "a/b"; a rate is never a quantity. + internal static bool IsRate(string? unit) => + !string.IsNullOrWhiteSpace(unit) && TrySplitRate(unit.Trim(), out _, out _); + + /// Splits "a/b" with exactly one slash and two non-blank sides; the sides are trimmed. + internal static bool TrySplitRate(string unit, out string numerator, out string denominator) + { + numerator = string.Empty; + denominator = string.Empty; + + var slash = unit.IndexOf('/', StringComparison.Ordinal); + if (slash < 0 || unit.IndexOf('/', slash + 1) >= 0) + { + return false; + } + + numerator = unit[..slash].Trim(); + denominator = unit[(slash + 1)..].Trim(); + return numerator.Length > 0 && denominator.Length > 0; + } + + private static List BuildTable() + { + var table = new List(); + + void Add(UnitInfo info, params string[] aliases) + { + table.Add(new UnitAlias(info.Symbol, info, CaseSensitive: false)); + table.AddRange(aliases.Select(alias => new UnitAlias(alias, info, CaseSensitive: false))); + } + + // The milli symbols only exist exactly as written; their spelled-out forms cannot be mistaken. + void AddMilli(UnitInfo info, params string[] aliases) + { + table.Add(new UnitAlias(info.Symbol, info, CaseSensitive: true)); + table.AddRange(aliases.Select(alias => new UnitAlias(alias, info, CaseSensitive: false))); + } + + // Energy, reference kWh. Joules only appear on district-heat bills; 1 kWh = 3.6 MJ exactly. + AddMilli( + new UnitInfo(MilliwattHour, UnitDimension.Energy, 0.000_001), + "milliwatthour", "milliwatthours", "milliwatt-hour", "milliwatt-hours", "milliwattstunde", "milliwattstunden"); + Add( + new UnitInfo(WattHour, UnitDimension.Energy, 0.001), + "watthour", "watthours", "watt-hour", "watt-hours", "wattstunde", "wattstunden"); + Add( + new UnitInfo(KilowattHour, UnitDimension.Energy, 1), + "kilowatthour", "kilowatthours", "kilowatt-hour", "kilowatt-hours", "kilowattstunde", "kilowattstunden"); + Add( + new UnitInfo(MegawattHour, UnitDimension.Energy, 1_000), + "megawatthour", "megawatthours", "megawatt-hour", "megawatt-hours", "megawattstunde", "megawattstunden"); + Add( + new UnitInfo(GigawattHour, UnitDimension.Energy, 1_000_000), + "gigawatthour", "gigawatthours", "gigawatt-hour", "gigawatt-hours", "gigawattstunde", "gigawattstunden"); + Add(new UnitInfo(Megajoule, UnitDimension.Energy, 1 / 3.6), "megajoule", "megajoules"); + Add(new UnitInfo(Gigajoule, UnitDimension.Energy, 1_000 / 3.6), "gigajoule", "gigajoules"); + + // Power, reference kW. + AddMilli(new UnitInfo(Milliwatt, UnitDimension.Power, 0.000_001), "milliwatt", "milliwatts"); + Add(new UnitInfo(Watt, UnitDimension.Power, 0.001), "watt", "watts"); + Add(new UnitInfo(Kilowatt, UnitDimension.Power, 1), "kilowatt", "kilowatts"); + Add(new UnitInfo(Megawatt, UnitDimension.Power, 1_000), "megawatt", "megawatts"); + Add(new UnitInfo(Gigawatt, UnitDimension.Power, 1_000_000), "gigawatt", "gigawatts"); + + // Volume, reference L. + Add( + new UnitInfo(Litre, UnitDimension.Volume, 1), + "liter", "litre", "liters", "litres", "ltr", "lt", "dm3", "dm³"); + Add(new UnitInfo(Hectolitre, UnitDimension.Volume, 100), "hektoliter", "hectoliter", "hectolitre"); + Add( + new UnitInfo(CubicMetre, UnitDimension.Volume, 1_000), + "m3", "m^3", "cbm", "kubikmeter", "cubicmeter", "cubicmetre", "cubicmeters", "cubicmetres"); + + // Mass, reference kg (pellets, wood). + Add(new UnitInfo(Gram, UnitDimension.Mass, 0.001), "gram", "gramm"); + Add(new UnitInfo(Kilogram, UnitDimension.Mass, 1), "kilogram", "kilogramm"); + Add(new UnitInfo(Tonne, UnitDimension.Mass, 1_000), "tonne", "tonnes", "tonnen"); + + // Time, reference h (runtime counters). + Add( + new UnitInfo(Hour, UnitDimension.Time, 1), + "hr", "hrs", "hour", "hours", "std", "std.", "stunde", "stunden", "bh", "betriebsstunden"); + Add(new UnitInfo(Minute, UnitDimension.Time, 1 / 60d), "mins", "minute", "minutes", "minuten"); + Add(new UnitInfo(Second, UnitDimension.Time, 1 / 3_600d), "sec", "secs", "second", "seconds", "sekunde", "sekunden"); + + return table; + } + + private sealed class UnitComparer : IEqualityComparer + { + public bool Equals(string? x, string? y) => AreSame(x, y); + + public int GetHashCode(string? obj) => Normalize(obj).GetHashCode(StringComparison.Ordinal); + } +} + +/// One spelling the unit table recognises. +/// The spelling, as it is looked up (whitespace removed). +/// The unit it names. +/// True when only this exact case names the unit (the milli symbols). +internal sealed record UnitAlias(string Alias, UnitInfo Info, bool CaseSensitive); diff --git a/src/Core/Analysis/Rollups/RollupBuilder.cs b/src/Core/Analysis/Rollups/RollupBuilder.cs new file mode 100644 index 0000000..b4c75ef --- /dev/null +++ b/src/Core/Analysis/Rollups/RollupBuilder.cs @@ -0,0 +1,188 @@ +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Rollups; + +/// Markers a rollup bucket carries besides its amounts (D-12). Stored as an int. +[Flags] +public enum RollupFlags +{ + None = 0, + + /// + /// A first reading with an unknown start is booked in the bucket — the baseline-delta flag (A-01). Its + /// amount is real, but no run covers the time it accrued over, so the bucket is partial, never a confident + /// total, and the coverage evaluator takes this flag as its input. + /// + OpeningBalance = 1, + + /// + /// At least one row of the bucket is a share of an interval divided at local month boundaries, or a + /// register that stood still across them (, A-02). + /// + Divided = 2, +} + +/// +/// One local day's or local month's totals of one kind for one meter (D-12): the consumption_rollup and +/// consumption_rollup_month row shape, without the meter. +/// +/// The local day, or for a month the first day of the local month. +/// Consumption or generation, as the rows are stored. +/// The sum of every row's amount; signed. +/// The part booked from measured readings. +/// The part booked from manually entered readings. +/// The part booked from imported readings. +/// The part divided across months, coalesced, interpolated or otherwise inferred. +/// How many consumption rows the bucket sums. +/// Opening balance and divided markers. +/// +/// The latest end of a source interval among the bucket's rows (A-05): a month label row ends where its month +/// ends, so a bucket whose rows end after a reader's now is recorded after now, not an actual (D-04). +/// +public sealed record RollupBucket( + DateOnly Start, + ConsumptionKind Kind, + double Amount, + double Measured, + double Manual, + double Imported, + double Estimated, + int Rows, + RollupFlags Flags, + DateTimeOffset MaxIntervalEnd) +{ + /// True when a first reading with an unknown start is booked in the bucket (A-01). + public bool HasOpeningBalance => Flags.HasFlag(RollupFlags.OpeningBalance); + + /// Where the bucket's amount comes from, from its per-quality parts (D-14). + public Provenance Provenance => + ProvenanceRules.ProvenanceOf(Measured, Manual, Imported, Estimated, HasOpeningBalance, derived: false); +} + +/// A meter's rollups: local days and local months, each ordered by start then kind. +public sealed record MeterRollups(IReadOnlyList Days, IReadOnlyList Months) +{ + public static MeterRollups Empty { get; } = new([], []); +} + +/// +/// Sums one meter's normalized rows into local days and local months (D-12), the buckets the analysis reads +/// instead of scanning consumption. +/// +/// +/// +/// A row belongs to the local day and month of its stamp () in the instance zone. +/// The engine already stamps every row inside the day and month it describes — a divided share inside its month +/// (GapAttribution), a reading at a local midnight one second before it (D-11), a month label inside its +/// month — so the stamp is the one place to file it, and summing the rollups over any range of whole local days +/// gives exactly what summing the rows would. +/// +/// +/// The amount is split by the quality of the rows (brief §4.3): measured, manual and imported readings keep +/// their own column; estimated and interpolated rows — divided shares, coalesced rows, rejected decreases — +/// share the estimated one. Provenance is derived from those parts, never stored as a guess. +/// +/// +/// Months are summed from the rows, not from the days, in the rows' own order, so rebuilding the same rows +/// gives bit-identical buckets and a diff write touches nothing that did not change. +/// +/// +public static class RollupBuilder +{ + /// The day and month buckets of one meter's rows. Rows of other meters are the caller's mistake and are not filtered. + /// One meter's normalized rows, as the engine returned them (their intervals are optional). + /// The instance timezone the rows were normalized in. + public static MeterRollups Build(IEnumerable rows, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(rows); + ArgumentNullException.ThrowIfNull(zone); + + var days = new SortedDictionary<(DateOnly Start, ConsumptionKind Kind), Accumulator>(); + var months = new SortedDictionary<(DateOnly Start, ConsumptionKind Kind), Accumulator>(); + + foreach (var row in rows.OrderBy(r => r.Time).ThenBy(r => r.Kind)) + { + var day = LocalDay(row.Time, zone); + Add(days, (day, row.Kind), row); + Add(months, (MonthOf(day), row.Kind), row); + } + + return new MeterRollups( + [.. days.Select(d => d.Value.ToBucket(d.Key.Start, d.Key.Kind))], + [.. months.Select(m => m.Value.ToBucket(m.Key.Start, m.Key.Kind))]); + } + + /// The local calendar day an instant falls on in . + public static DateOnly LocalDay(DateTimeOffset instant, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + + return DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(instant, zone).DateTime); + } + + /// The first day of the month lies in — the key of its month bucket. + public static DateOnly MonthOf(DateOnly day) => new(day.Year, day.Month, 1); + + private static void Add( + SortedDictionary<(DateOnly Start, ConsumptionKind Kind), Accumulator> buckets, + (DateOnly Start, ConsumptionKind Kind) key, + Consumption row) + { + if (!buckets.TryGetValue(key, out var bucket)) + { + bucket = new Accumulator(); + buckets[key] = bucket; + } + + bucket.Add(row); + } + + /// The running totals of one bucket. + private sealed class Accumulator + { + private double _amount; + private double _measured; + private double _manual; + private double _imported; + private double _estimated; + private int _rows; + private RollupFlags _flags; + private DateTimeOffset _maxIntervalEnd = DateTimeOffset.MinValue; + + public void Add(Consumption row) + { + _amount += row.Amount; + switch (row.Quality) + { + case ReadingQuality.Measured: + _measured += row.Amount; + break; + case ReadingQuality.Manual: + _manual += row.Amount; + break; + case ReadingQuality.Imported: + _imported += row.Amount; + break; + default: + // Estimated and Interpolated — and any quality this build does not know, which is at best + // inferred. + _estimated += row.Amount; + break; + } + + _rows++; + _flags |= row.OpeningBalance ? RollupFlags.OpeningBalance : RollupFlags.None; + _flags |= row.Divided ? RollupFlags.Divided : RollupFlags.None; + + var end = (row.IntervalEnd ?? row.Time).ToUniversalTime(); + if (end > _maxIntervalEnd) + { + _maxIntervalEnd = end; + } + } + + public RollupBucket ToBucket(DateOnly start, ConsumptionKind kind) => + new(start, kind, _amount, _measured, _manual, _imported, _estimated, _rows, _flags, _maxIntervalEnd); + } +} diff --git a/src/Core/Analysis/Time/AnalysisTokens.cs b/src/Core/Analysis/Time/AnalysisTokens.cs new file mode 100644 index 0000000..e4f877f --- /dev/null +++ b/src/Core/Analysis/Time/AnalysisTokens.cs @@ -0,0 +1,203 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace MeterVault.Core.Analysis; + +/// +/// The invariant URL tokens of the analysis state (D-02, D-05, D-06, D-46): period, bucket, +/// compare and the ISO from/to dates. +/// +/// +/// +/// Tokens are stable identifiers, never localized labels, so a link shared between a German and an English +/// browser opens the same analysis. Formatting always writes the canonical lower-case token. Parsing is +/// lenient about case and surrounding blanks, and accepts the brief's spelled-out previous-period / +/// previous-year as aliases. +/// +/// +/// Every TryParse method returns false instead of throwing, whatever the input: a hand-edited or +/// stale URL must fall back to the page default with a notice (D-02), never break the page. +/// +/// +public static class AnalysisTokens +{ + /// The ISO date format of from/to. + public const string DateFormat = "yyyy-MM-dd"; + + private const string YearPrefix = "year:"; + + private static readonly (PeriodPreset Value, string Token)[] Periods = + [ + (PeriodPreset.MonthToDate, "mtd"), + (PeriodPreset.LastMonth, "last-month"), + (PeriodPreset.YearToDate, "ytd"), + (PeriodPreset.PreviousYear, "prev-year"), + (PeriodPreset.Last12Months, "12m"), + (PeriodPreset.Last24Months, "24m"), + (PeriodPreset.AllHistory, "all"), + (PeriodPreset.Custom, "custom"), + ]; + + private static readonly (BucketSize Value, string Token)[] Buckets = + [ + (BucketSize.Auto, "auto"), + (BucketSize.Day, "day"), + (BucketSize.Week, "week"), + (BucketSize.Month, "month"), + (BucketSize.Year, "year"), + ]; + + private static readonly (ComparisonKind Value, string Token)[] Comparisons = + [ + (ComparisonKind.None, "none"), + (ComparisonKind.PreviousPeriod, "prev-period"), + (ComparisonKind.PreviousYear, "prev-year"), + (ComparisonKind.PreviousPeriod, "previous-period"), + (ComparisonKind.PreviousYear, "previous-year"), + ]; + + /// The token of a period preset (mtd, last-month, …). + public static string Format(PeriodPreset preset) => TokenOf(Periods, preset, nameof(preset)); + + /// The token of a bucket size (auto, day, …). + public static string Format(BucketSize size) => TokenOf(Buckets, size, nameof(size)); + + /// The token of a comparison (none, prev-period, prev-year, year:2025). + /// A request without a year. + public static string Format(ComparisonRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.Kind != ComparisonKind.Year) + { + return TokenOf(Comparisons, request.Kind, nameof(request)); + } + + return request.Year is { } year + ? YearPrefix + year.ToString("D4", CultureInfo.InvariantCulture) + : throw new ArgumentException("A year comparison needs a year.", nameof(request)); + } + + /// A local date as yyyy-MM-dd, whatever the current culture. + public static string FormatDate(DateOnly date) => date.ToString(DateFormat, CultureInfo.InvariantCulture); + + public static bool TryParsePeriod(string? token, out PeriodPreset preset) => TryLookUp(Periods, token, out preset); + + public static bool TryParseBucket(string? token, out BucketSize size) => TryLookUp(Buckets, token, out size); + + /// + /// Parses none, prev-period, prev-year or year:YYYY (four digits, within the + /// supported years of ). + /// + public static bool TryParseComparison(string? token, [NotNullWhen(true)] out ComparisonRequest? request) + { + request = null; + if (token is null) + { + return false; + } + + var text = token.AsSpan().Trim(); + if (text.StartsWith(YearPrefix, StringComparison.OrdinalIgnoreCase)) + { + var digits = text[YearPrefix.Length..]; + if (digits.Length == 4 + && int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var year) + && year >= PeriodResolver.MinSupportedDate.Year + && year <= PeriodResolver.MaxSupportedDate.Year) + { + request = new ComparisonRequest(ComparisonKind.Year, year); + return true; + } + + return false; + } + + if (!TryLookUp(Comparisons, token, out var kind)) + { + return false; + } + + request = kind == ComparisonKind.None ? ComparisonRequest.None : new ComparisonRequest(kind); + return true; + } + + /// + /// Parses an exact yyyy-MM-dd date within the supported range; anything else (other layouts, + /// 29 February of a common year, year 9999) is rejected. + /// + public static bool TryParseDate(string? token, out DateOnly date) + { + date = default; + if (token is null) + { + return false; + } + + if (!DateOnly.TryParseExact(token.AsSpan().Trim(), DateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) + || parsed < PeriodResolver.MinSupportedDate + || parsed > PeriodResolver.MaxSupportedDate) + { + return false; + } + + date = parsed; + return true; + } + + /// + /// Parses the from/to pair of a custom period: both valid dates, in order + /// (). The inclusive to is the last local day. + /// + public static bool TryParseCustomRange(string? from, string? to, out DateOnly first, out DateOnly last) + { + first = default; + last = default; + if (!TryParseDate(from, out var parsedFirst) + || !TryParseDate(to, out var parsedLast) + || !PeriodResolver.IsValidCustomRange(parsedFirst, parsedLast)) + { + return false; + } + + first = parsedFirst; + last = parsedLast; + return true; + } + + private static string TokenOf((T Value, string Token)[] table, T value, string paramName) + where T : struct, Enum + { + foreach (var (candidate, token) in table) + { + if (EqualityComparer.Default.Equals(candidate, value)) + { + return token; + } + } + + throw new ArgumentOutOfRangeException(paramName, value, "No URL token for this value."); + } + + private static bool TryLookUp((T Value, string Token)[] table, string? token, out T value) + where T : struct, Enum + { + value = default; + if (token is null) + { + return false; + } + + var text = token.AsSpan().Trim(); + foreach (var (candidate, name) in table) + { + if (text.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + value = candidate; + return true; + } + } + + return false; + } +} diff --git a/src/Core/Analysis/Time/BucketPlanner.cs b/src/Core/Analysis/Time/BucketPlanner.cs new file mode 100644 index 0000000..810344c --- /dev/null +++ b/src/Core/Analysis/Time/BucketPlanner.cs @@ -0,0 +1,241 @@ +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. 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); diff --git a/src/Core/Analysis/Time/Change.cs b/src/Core/Analysis/Time/Change.cs new file mode 100644 index 0000000..2c0e482 --- /dev/null +++ b/src/Core/Analysis/Time/Change.cs @@ -0,0 +1,80 @@ +namespace MeterVault.Core.Analysis; + +/// +/// The change from a previous value to a current one (D-08): the absolute difference whenever both values +/// are known, and a percentage only where it means something. +/// +/// +/// +/// The old dashboard had two rules — one reported +0 % against a zero baseline, the other divided by the +/// absolute value of a negative one — so the same pair of numbers read differently on two pages. Here a +/// percentage needs a positive baseline: against zero it is undefined, and against a negative one (a net +/// credit, a grid balance) its sign would say the opposite of what happened. The absolute difference is +/// always given, so a missing percentage never hides the change itself. +/// +/// +/// A missing or non-finite value is unknown, never zero: the change is then unavailable rather than a +/// confident "−100 %". Whether up is good is the caller's call — more consumption is not, more generation is. +/// +/// +/// Current minus previous; null when either is unknown. +/// The change in percent of the previous value (12.5 means +12.5 %); null when not applicable. +/// +1, 0 or −1 by the sign of ; 0 within the tolerance and when unknown. +public sealed record Change(double? Absolute, double? Percent, int Direction) +{ + /// + /// The default for differences and baselines that count as zero: floating-point noise between sums of + /// the same rows. Callers that display a rounded value pass their own (see ). + /// + public const double Tolerance = 1e-9; + + /// No change can be stated: a value is missing. + public static readonly Change Unavailable = new(null, null, 0); + + /// True when both values were known, so is set. + public bool IsAvailable => Absolute is not null; + + /// True when the percentage is meaningful (a positive, known baseline). + public bool PercentApplicable => Percent is not null; + + /// The change from to . + /// The current value; null when unknown. + /// The baseline; null when unknown. + /// + /// How close to zero a difference or a baseline may be and still count as zero. The default only absorbs + /// floating-point noise; a figure shown rounded should pass half its display step (0.005 for cents), or + /// a difference of 0.004 € reads "+0.00 €" beside an upward arrow. The same tolerance decides whether the + /// baseline is positive, so a percentage is never taken of a baseline that displays as zero. The + /// absolute difference itself is returned unrounded. + /// + /// is negative or not finite. + public static Change Between(double? current, double? previous, double tolerance = Tolerance) + { + if (!double.IsFinite(tolerance) || tolerance < 0) + { + throw new ArgumentOutOfRangeException(nameof(tolerance), tolerance, "The tolerance must be a finite, non-negative number."); + } + + if (current is not { } now || previous is not { } before || !double.IsFinite(now) || !double.IsFinite(before)) + { + return Unavailable; + } + + var absolute = now - before; + if (!double.IsFinite(absolute)) + { + return Unavailable; + } + + var direction = Math.Abs(absolute) <= tolerance ? 0 : Math.Sign(absolute); + + double? percent = null; + if (before > tolerance) + { + var ratio = absolute / before * 100.0; + percent = double.IsFinite(ratio) ? ratio : null; + } + + return new Change(absolute, percent, direction); + } +} diff --git a/src/Core/Analysis/Time/ComparisonResolver.cs b/src/Core/Analysis/Time/ComparisonResolver.cs new file mode 100644 index 0000000..4e7b94f --- /dev/null +++ b/src/Core/Analysis/Time/ComparisonResolver.cs @@ -0,0 +1,538 @@ +using System.Diagnostics.CodeAnalysis; +using MeterVault.Core.Normalization; + +namespace MeterVault.Core.Analysis; + +/// +/// Finds the period a resolved period is compared against (D-06): the previous period, the same period a +/// year earlier, or a named calendar year — and maps instants, dates and buckets of the current period into +/// it, so matched coverage (D-07), chart overlays and exports all use the one mapping the cut-off uses. +/// +/// +/// +/// Shifting uses local calendar units, never durations. A range of whole years shifts by years and a +/// range of whole months by months, so "February" compares with "January" whole, not with the 28 days +/// before 1 February; anything else shifts by days. A month to date or year to date is aligned as the +/// month or year it names (), so it compares with the same +/// elapsed part of the previous month or year, and "last 12/24 months" compares with the 12/24 months +/// before. +/// +/// +/// An instant maps as a local date plus wall-clock time (), +/// so a cut at 19 September 14:37 compares with 19 August 14:37 whatever the DST offsets. A time that does +/// not exist on the target day takes the first instant after the gap; one that exists twice takes the +/// occurrence with now's offset, else the first. A day the target month lacks (the 31st, 29 February) +/// collapses onto that month's end — for a cut-off and for the start of a range alike: 31 March compares +/// with all of February, and a range that starts on 30 March starts, in February's timeline, where March +/// begins. Days the comparison lacks are compared with nothing rather than with a neighbouring day, which is +/// what keeps the mapping a single non-decreasing function that D-07 can invert. +/// +/// +/// The result states both the effective range and the named one, so the UI can show exactly which dates +/// are compared (brief §4.2); its bounds are the images of the current period's bounds, and +/// maps the current buckets into it one by one. +/// +/// +public static class ComparisonResolver +{ + /// Resolves for . + /// The period being analysed. + /// What to compare it with. + /// + /// The captured "now"; defaults to . Its offset picks the occurrence of + /// an ambiguous cut-off (kept as ), and a comparison is + /// never allowed to reach past it. + /// + public static ComparisonResolution Resolve(ResolvedPeriod current, ComparisonRequest request, DateTimeOffset? now = null) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(request); + + var nowUtc = (now ?? current.Now).ToUniversalTime(); + + if (request.Kind == ComparisonKind.None) + { + return NotApplicable(request, ComparisonUnavailableReason.NotRequested); + } + + // "No history" is also all history; the more specific answer goes first. + if (current.HasNoHistory()) + { + return NotApplicable(request, ComparisonUnavailableReason.NoCurrentPeriod); + } + + if (current.Preset == PeriodPreset.AllHistory) + { + return NotApplicable(request, ComparisonUnavailableReason.AllHistory); + } + + if (current.HasNotStarted()) + { + return NotApplicable(request, ComparisonUnavailableReason.CurrentNotYetOccurred); + } + + var first = current.FirstDay; + var nominalLast = current.NominalLastDay(); + if (nominalLast == DateOnly.MaxValue) + { + return NotApplicable(request, ComparisonUnavailableReason.OutOfRange); + } + + var alignment = AlignmentOf(first, nominalLast); + + ComparisonShift shift; + switch (request.Kind) + { + case ComparisonKind.PreviousPeriod: + shift = alignment switch + { + Alignment.Years => new ComparisonShift(ComparisonShiftUnit.Year, nominalLast.Year - first.Year + 1), + Alignment.Months => new ComparisonShift(ComparisonShiftUnit.Month, LocalCalendar.MonthsSpanned(first, nominalLast)), + _ => new ComparisonShift(ComparisonShiftUnit.Day, nominalLast.DayNumber - first.DayNumber + 1), + }; + break; + + case ComparisonKind.PreviousYear: + // The same dates one calendar year earlier, whatever the alignment. + shift = new ComparisonShift(ComparisonShiftUnit.Year, 1); + break; + + case ComparisonKind.Year: + if (request.Year is not { } year) + { + return NotApplicable(request, ComparisonUnavailableReason.MissingYear); + } + + if (alignment != Alignment.Years || nominalLast.Year != first.Year) + { + return NotApplicable(request, ComparisonUnavailableReason.NotYearAligned); + } + + if (year == first.Year) + { + return NotApplicable(request, ComparisonUnavailableReason.SameYear); + } + + // A year the tokens could never name (the request is a plain record) is refused before the + // shift is computed, which could otherwise overflow. + if (year < PeriodResolver.MinSupportedDate.Year || year > PeriodResolver.MaxSupportedDate.Year) + { + return NotApplicable(request, ComparisonUnavailableReason.OutOfRange); + } + + shift = new ComparisonShift(ComparisonShiftUnit.Year, first.Year - year); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(request), request.Kind, "Unknown comparison kind."); + } + + // Checked before any date is shifted, so no calendar arithmetic can throw on the way. + if (!TryShiftSupported(first, shift, out var shiftedFirst) || !TryShiftSupported(nominalLast.AddDays(1), shift, out var shiftedEnd)) + { + return NotApplicable(request, ComparisonUnavailableReason.OutOfRange); + } + + return Shifted(current, request, shift, shiftedFirst, shiftedEnd, nowUtc); + } + + /// + /// The local day a day boundary maps to: the same day number in the shifted month or year, or — when + /// the target month lacks that day (the 31st, 29 February) — the first day after that month. Days the + /// target lacks thus collapse onto its end: with a one-month shift, the days 29 – 31 March map to the + /// empty range [1 March, 1 March), and 28 March to [28 February, 1 March). + /// + /// + /// Read the result as the start of a day, like and the exclusive + /// : the local midnight it starts at is what + /// maps the day's own + /// midnight to. + /// + /// The shift leaves the range of . + public static DateOnly ShiftDate(DateOnly date, ComparisonShift shift) + { + ArgumentNullException.ThrowIfNull(shift); + + var target = CalendarShift(date, shift); + return Exists(date, target, shift) ? target : LocalCalendar.MonthStart(target).AddMonths(1); + } + + /// + /// Maps an instant of the current timeline into the comparison timeline (D-06): the same wall-clock time + /// on the shifted local date. This is the mapping the cut-off uses, public so that matched coverage + /// (D-07) shifts covered ranges exactly the same way. It is non-decreasing, as D-07's inversion needs. + /// + /// + /// + /// The start of a local day maps to the start of the target day (), even + /// where midnight is skipped or repeated, so bucket and period bounds map onto bucket and period + /// bounds. + /// Any instant on a day the target month lacks maps to where that month ends (the local midnight + /// after its last day): the missing days have no counterpart, so a range lying wholly in them maps to an + /// empty range, and one that starts in them matches from the next month's first instant. + /// A wall-clock time the target day skips (spring gap) takes the first instant after the gap. + /// A wall-clock time the target day repeats (autumn fold) takes the occurrence with + /// — now's offset, so a cut-off taken in summer time maps to summer + /// time — and otherwise the first occurrence. When the instant itself lies in a repeated hour of the + /// current timeline, the pass is kept instead: first with first, second with second. + /// An instant in the second pass of a repeated hour whose target is not repeated maps like + /// the end of that hour (03:00 in Berlin). Its wall-clock time was already reached once in the first + /// pass; mapping it there again would run backwards, and the whole first pass has elapsed by then. + /// + /// + /// An instant of the current timeline. + /// The comparison's shift (). + /// The instance zone. + /// The offset of the captured now (). + /// The shift leaves the range of . + public static DateTimeOffset MapInstant(DateTimeOffset instant, ComparisonShift shift, TimeZoneInfo zone, TimeSpan preferredOffset) + { + ArgumentNullException.ThrowIfNull(shift); + ArgumentNullException.ThrowIfNull(zone); + + var local = TimeZoneInfo.ConvertTime(instant, zone); + var wall = local.DateTime; + var date = DateOnly.FromDateTime(wall); + var target = CalendarShift(date, shift); + + if (!Exists(date, target, shift) || GapAttribution.IsLocalMidnight(instant, zone)) + { + return LocalCalendar.Midnight(ShiftDate(date, shift), zone); + } + + var targetWall = target.ToDateTime(TimeOnly.FromDateTime(wall)); + if (zone.IsAmbiguousTime(wall)) + { + var secondPass = local.Offset == zone.GetAmbiguousTimeOffsets(wall).Min(); + if (zone.IsAmbiguousTime(targetWall)) + { + var offsets = zone.GetAmbiguousTimeOffsets(targetWall); + return new DateTimeOffset(targetWall, secondPass ? offsets.Min() : offsets.Max()).ToUniversalTime(); + } + + if (secondPass && LocalCalendar.FoldEnd(wall, zone) is var foldEnd && !zone.IsAmbiguousTime(foldEnd)) + { + return MapInstant(new DateTimeOffset(foldEnd, zone.GetUtcOffset(foldEnd)), shift, zone, preferredOffset); + } + } + + return LocalCalendar.InstantOf(targetWall, zone, preferredOffset); + } + + /// + /// Maps each of the current period's buckets into the comparison, paired by index: the chart overlay of + /// the previous period (brief §7.1), the per-bucket comparison value of the CSV export (D-55), and the + /// buckets D-07 trims to. + /// + /// + /// + /// A comparison bucket is the image of its current bucket under + /// and , clipped to the comparison. Because the current buckets tile the current + /// period and the mapping sends its bounds onto the comparison's, the images tile the comparison: the + /// paired values add up to the comparison's total. Where the calendars differ, a pair shows it rather than + /// hiding it — day buckets of a whole February against January pair 28 February with 28 – 31 January, + /// and days of March that February lacks pair with empty buckets. + /// + /// + /// A named later year still running () stops at now: the + /// bucket holding now is cut there (its keeps the unit's end) + /// and later buckets are empty. + /// + /// + /// The period the comparison was resolved for. + /// Its comparison period. + /// The current plan's buckets (). + public static IReadOnlyList PairBuckets(ResolvedPeriod current, ComparisonPeriod comparison, IReadOnlyList buckets) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(comparison); + ArgumentNullException.ThrowIfNull(buckets); + + var zone = current.Zone; + var shift = comparison.Shift; + var nowEndDay = LocalCalendar.DateOf(comparison.To, zone).AddDays(1); + + var pairs = new List(buckets.Count); + foreach (var bucket in buckets) + { + var mappedFrom = comparison.MapInstant(bucket.From, zone); + var mappedTo = comparison.MapInstant(bucket.To, zone); + var from = Clamp(mappedFrom, comparison.From, comparison.To); + var to = Clamp(mappedTo, from, comparison.To); + + var firstDay = ShiftDate(bucket.FirstDay, shift); + var endDay = ShiftDate(bucket.EndDay, shift); + var unitEnd = bucket.NominalEndDay is { } nominal ? ShiftDate(nominal, shift) : endDay; + if (mappedTo > comparison.To) + { + // Only a comparison capped at now ends before its image: the days after today have not happened. + endDay = nowEndDay < endDay ? nowEndDay : endDay; + endDay = endDay < firstDay ? firstDay : endDay; + } + + DateOnly? nominalEnd = unitEnd > endDay ? unitEnd : null; + pairs.Add(new BucketPair(bucket, new AnalysisBucket(firstDay, endDay, from, to, bucket.Size, nominalEnd))); + } + + return pairs; + } + + private static ComparisonResolution Shifted( + ResolvedPeriod current, ComparisonRequest request, ComparisonShift shift, DateOnly first, DateOnly end, DateTimeOffset now) + { + var zone = current.Zone; + var preferredOffset = zone.GetUtcOffset(now); + + // The bounds are the images of the current bounds: From of From, To of the cut (or of the end). + var from = LocalCalendar.Midnight(first, zone); + var nominalEnd = LocalCalendar.Midnight(end, zone); + var to = MapInstant(current.To, shift, zone, preferredOffset); + to = to > nominalEnd ? nominalEnd : to; + + // Only a named later year can lie after now; its actuals stop at now like any other range (D-04). + if (from >= now) + { + return NotApplicable(request, ComparisonUnavailableReason.ComparisonNotYetOccurred); + } + + var cappedAtNow = to > now; + if (cappedAtNow) + { + to = now; + } + + to = to < from ? from : to; + + // A current period with nothing elapsed yet (month to date at 00:00 on the 1st) compares with an + // equally empty image; one with elapsed time whose image is empty has nothing to compare with. + if (to == from && current.To > current.From) + { + return NotApplicable(request, ComparisonUnavailableReason.Empty); + } + + var period = new ComparisonPeriod( + request.Kind, + first, + to > from ? LocalCalendar.DateOf(to.AddTicks(-1), zone) : first, + end.AddDays(-1), + from, + to, + IsCutOff: to < nominalEnd, + cappedAtNow, + shift, + preferredOffset); + + return new ComparisonResolution(request, period, ComparisonUnavailableReason.None); + } + + /// + /// Shifts a date (a range's first day or exclusive end) if the result stays inside the supported dates — + /// the bounds of any custom range, so a comparison is always a range a user could have asked for. The + /// target is checked arithmetically first, so never throws near year 1 or 9999. + /// + private static bool TryShiftSupported(DateOnly date, ComparisonShift shift, out DateOnly shifted) + { + shifted = default; + var min = PeriodResolver.MinSupportedDate; + var max = PeriodResolver.MaxSupportedDate.AddDays(1); + + var inRange = shift.Unit switch + { + ComparisonShiftUnit.Day => Within((long)date.DayNumber - shift.Count, min.DayNumber, max.DayNumber), + ComparisonShiftUnit.Month => Within(MonthIndex(date) - shift.Count, MonthIndex(min), MonthIndex(max)), + ComparisonShiftUnit.Year => Within((long)date.Year - shift.Count, min.Year, max.Year), + _ => throw new ArgumentOutOfRangeException(nameof(shift), shift.Unit, "Unknown shift unit."), + }; + + if (!inRange) + { + return false; + } + + shifted = ShiftDate(date, shift); + return shifted >= min && shifted <= max; + } + + private static bool Within(long value, long min, long max) => value >= min && value <= max; + + private static long MonthIndex(DateOnly date) => (date.Year * 12L) + date.Month - 1; + + /// The plain calendar shift: AddMonths/AddYears clamp a missing day to the month's last day. + private static DateOnly CalendarShift(DateOnly date, ComparisonShift shift) => shift.Unit switch + { + ComparisonShiftUnit.Day => date.AddDays(-shift.Count), + ComparisonShiftUnit.Month => date.AddMonths(-shift.Count), + ComparisonShiftUnit.Year => date.AddYears(-shift.Count), + _ => throw new ArgumentOutOfRangeException(nameof(shift), shift.Unit, "Unknown shift unit."), + }; + + /// False when AddMonths/AddYears had to clamp: the target month lacks the day. + private static bool Exists(DateOnly date, DateOnly target, ComparisonShift shift) => + shift.Unit == ComparisonShiftUnit.Day || target.Day == date.Day; + + private static DateTimeOffset Clamp(DateTimeOffset value, DateTimeOffset min, DateTimeOffset max) => + value < min ? min : value > max ? max : value; + + private static Alignment AlignmentOf(DateOnly first, DateOnly last) + { + if (first is { Month: 1, Day: 1 } && last is { Month: 12, Day: 31 }) + { + return Alignment.Years; + } + + return first.Day == 1 && LocalCalendar.IsMonthEnd(last) ? Alignment.Months : Alignment.Days; + } + + private static ComparisonResolution NotApplicable(ComparisonRequest request, ComparisonUnavailableReason reason) => + new(request, null, reason); + + private enum Alignment + { + Days, + Months, + Years, + } +} + +/// The calendar unit a comparison period was shifted by. +public enum ComparisonShiftUnit +{ + Day, + Month, + Year, +} + +/// How far back a comparison lies: local calendar units (negative for a named later year). +public sealed record ComparisonShift(ComparisonShiftUnit Unit, int Count); + +/// Why no comparison period exists. A code the UI localizes. +public enum ComparisonUnavailableReason +{ + None, + + /// compare=none. + NotRequested, + + /// All history has nothing before it to compare with. + AllHistory, + + /// The current period is the "no history" result: the scope has no data at all. + NoCurrentPeriod, + + /// The current period has not started, so there is no elapsed part to match. + CurrentNotYetOccurred, + + /// year:YYYY needs the current period to be exactly one calendar year (a year to date counts). + NotYearAligned, + + /// year:YYYY named the current period's own year. + SameYear, + + /// A request without a year. + MissingYear, + + /// The comparison lies entirely after now (a named later year). + ComparisonNotYetOccurred, + + /// + /// The current period has elapsed time, but its image in the comparison has none: every day of it is one + /// the comparison's calendar lacks (29 February compared with a common year). + /// + Empty, + + /// + /// The comparison would leave the supported dates ( … + /// ). + /// + OutOfRange, +} + +/// +/// The period a comparison reads. Like : inclusive local dates for display, +/// a half-open UTC range for queries. +/// +/// The comparison this period answers. +/// First local day. +/// +/// The local day holding the last compared instant (the cut-off's date for a cut comparison). When the +/// current period has nothing elapsed yet and the comparison is empty with it, . +/// +/// The last day of the shifted named range ("of August 1 – 31"). +/// Local midnight of , in UTC: the image of the current period's start. +/// The exclusive end: the image of the current period's end (the mapped cut-off, or the named range's end), or now. +/// True when stops before the named range ends — the "same elapsed part" of a to-date period. +/// True when the range reached past now and was cut there (a named later year). +/// How the range was derived from the current one. +/// +/// The UTC offset of the captured now. An instant mapped into this comparison whose wall-clock time repeats +/// takes the occurrence with this offset (D-06), so everything mapped later agrees with the cut-off. +/// +public sealed record ComparisonPeriod( + ComparisonKind Kind, + DateOnly FirstDay, + DateOnly LastDay, + DateOnly NominalLastDay, + DateTimeOffset From, + DateTimeOffset To, + bool IsCutOff, + bool CappedAtNow, + ComparisonShift Shift, + TimeSpan PreferredOffset) +{ + /// + /// Maps an instant of the current timeline into this comparison, exactly as its cut-off was mapped + /// () — + /// the non-decreasing shift matched coverage (D-07) takes. + /// + /// An instant of the current timeline. + /// The instance zone ( of the current period). + public DateTimeOffset MapInstant(DateTimeOffset instant, TimeZoneInfo zone) => + ComparisonResolver.MapInstant(instant, Shift, zone, PreferredOffset); + + /// + /// This comparison as a , so it is read, bucketed and matched like any + /// period (, the Infrastructure reader, matched coverage). + /// + /// + /// + /// It is a range resolved as of its own end. A cut comparison is to date + /// as of the mapped cut-off: Now == To, LastDay is the named range's last day (as a custom + /// range reaching past now keeps its requested day), and the day holding the cut is a partial edge day, + /// exactly like the current period's today. A comparison that ends at a local midnight is complete, with + /// the real now — unless the current period itself ends at a midnight (a to-date period at the instant + /// a day begins), in which case the comparison keeps the same empty last day the current one has. + /// + /// + /// Planned on its own, the comparison gets its own calendar buckets, which need not pair one to one with + /// the current ones (31 days of January against 28 of February). To chart the two against each other, + /// use . + /// + /// + /// The period this comparison was resolved for. + public ResolvedPeriod ToResolvedPeriod(ResolvedPeriod current) + { + ArgumentNullException.ThrowIfNull(current); + + var zone = current.Zone; + var toDate = IsCutOff && (!GapAttribution.IsLocalMidnight(To, zone) || GapAttribution.IsLocalMidnight(current.To, zone)); + if (!toDate) + { + return new ResolvedPeriod( + PeriodPreset.Custom, FirstDay, LastDay, From, To, current.Now, IsToDate: false, ExtendsPastNow: false, zone); + } + + var today = LocalCalendar.DateOf(To, zone); + return new ResolvedPeriod( + PeriodPreset.Custom, FirstDay, NominalLastDay, From, To, To, IsToDate: true, ExtendsPastNow: NominalLastDay > today, zone); + } +} + +/// A bucket of the current period and its image in the comparison period, paired by index. +public sealed record BucketPair(AnalysisBucket Current, AnalysisBucket Comparison); + +/// A comparison period, or why there is none. +public sealed record ComparisonResolution(ComparisonRequest Request, ComparisonPeriod? Period, ComparisonUnavailableReason Reason) +{ + [MemberNotNullWhen(true, nameof(Period))] + public bool IsApplicable => Period is not null; +} diff --git a/src/Core/Analysis/Time/LegacyPeriods.cs b/src/Core/Analysis/Time/LegacyPeriods.cs new file mode 100644 index 0000000..e6ae6d9 --- /dev/null +++ b/src/Core/Analysis/Time/LegacyPeriods.cs @@ -0,0 +1,117 @@ +namespace MeterVault.Core.Analysis; + +/// +/// Periods for the entry points that predate the analysis rework and keep their signatures (D-45): the REST API's +/// exact-instant bounds and the ranges the older read services take. Both become an ordinary +/// , cut at "now" like every other period (D-04), so what those entry points report is +/// what the analysis reader and the cost engine report for the same range. +/// +/// +/// +/// An instant range keeps its exact bounds (D-45): from need not be a local midnight, so the first bucket may +/// start inside a day and the last one may end inside a day. The reader answers such edges from the partial-day rows +/// (D-15). The period is a range whose and +/// are the local days the instants fall on. +/// +/// +/// Dates and instants outside [, ] +/// are clamped to it rather than rejected: a legacy caller asking for "everything since 0001" means everything. +/// An empty range (the end not after the start) resolves to the "no history" shape — nothing to plan. +/// +/// +public static class LegacyPeriods +{ + /// The half-open instant range [from, to), as of . + /// The first instant (inclusive), in any offset. + /// The end (exclusive), in any offset. + /// The captured now; actual figures stop there (D-04). + /// The instance zone. + public static ResolvedPeriod FromInstants(DateTimeOffset from, DateTimeOffset to, DateTimeOffset now, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + + var earliest = LocalCalendar.Midnight(PeriodResolver.MinSupportedDate, zone); + var latest = LocalCalendar.Midnight(PeriodResolver.MaxSupportedDate.AddDays(1), zone); + var start = Clamp(from.ToUniversalTime(), earliest, latest); + var end = Clamp(to.ToUniversalTime(), earliest, latest); + var nowUtc = now.ToUniversalTime(); + var first = LocalCalendar.DateOf(start, zone); + + if (end <= start) + { + return Empty(first, start, nowUtc, zone); + } + + var last = LocalCalendar.DateOf(end.AddTicks(-1), zone); + var today = LocalCalendar.DateOf(nowUtc, zone); + + if (end <= nowUtc) + { + return new ResolvedPeriod(PeriodPreset.Custom, first, last, start, end, nowUtc, IsToDate: false, ExtendsPastNow: false, zone); + } + + if (start >= nowUtc) + { + return new ResolvedPeriod(PeriodPreset.Custom, first, last, start, start, nowUtc, IsToDate: false, ExtendsPastNow: true, zone); + } + + return new ResolvedPeriod(PeriodPreset.Custom, first, last, start, nowUtc, nowUtc, IsToDate: true, ExtendsPastNow: last > today, zone); + } + + /// + /// The local days [first, endExclusive) — the older services' convention — as of : + /// a custom range from to the day before . + /// + public static ResolvedPeriod FromDates(DateOnly first, DateOnly endExclusive, DateTimeOffset now, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + + var start = first < PeriodResolver.MinSupportedDate ? PeriodResolver.MinSupportedDate : first; + var last = endExclusive > PeriodResolver.MaxSupportedDate.AddDays(1) ? PeriodResolver.MaxSupportedDate : endExclusive.AddDays(-1); + if (last < start) + { + var clamped = start > PeriodResolver.MaxSupportedDate ? PeriodResolver.MaxSupportedDate : start; + return Empty(clamped, LocalCalendar.Midnight(clamped, zone), now.ToUniversalTime(), zone); + } + + return PeriodResolver.Resolve(PeriodPreset.Custom, start, last, now, zone); + } + + /// + /// The period as a plan of exactly one bucket () — for a caller that only needs the + /// period's total, so nothing is cut finer than the period itself. No bucket for a period with nothing to read. + /// + public static BucketPlan WholePeriodPlan(ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(period); + + var bucket = PeriodBucket.Of(period); + return period.EffectiveLastDay() < period.FirstDay + ? new BucketPlan(bucket.Size, bucket.Size, [], 0, Refused: false, Suggested: null) + : new BucketPlan(bucket.Size, bucket.Size, [bucket], 1, Refused: false, Suggested: null); + } + + /// + /// The key a legacy result files a bucket under: the start of its whole calendar unit (the 1st of its month, 1 + /// January, the Monday of its week, the day) — what time_bucket in the instance zone returned, so a range + /// starting mid-month still reports that month by its 1st. + /// + public static DateOnly KeyOf(AnalysisBucket bucket) + { + ArgumentNullException.ThrowIfNull(bucket); + + return bucket.Size switch + { + BucketSize.Year => new DateOnly(bucket.FirstDay.Year, 1, 1), + BucketSize.Month => LocalCalendar.MonthStart(bucket.FirstDay), + BucketSize.Week => LocalCalendar.WeekStart(bucket.FirstDay), + _ => bucket.FirstDay, + }; + } + + private static ResolvedPeriod Empty(DateOnly first, DateTimeOffset start, DateTimeOffset now, TimeZoneInfo zone) => + new(PeriodPreset.Custom, first, first.AddDays(-1), start, start, now, IsToDate: false, ExtendsPastNow: false, zone); + + private static DateTimeOffset Clamp(DateTimeOffset value, DateTimeOffset min, DateTimeOffset max) => + value < min ? min : value > max ? max : value; +} diff --git a/src/Core/Analysis/Time/LocalCalendar.cs b/src/Core/Analysis/Time/LocalCalendar.cs new file mode 100644 index 0000000..aafa838 --- /dev/null +++ b/src/Core/Analysis/Time/LocalCalendar.cs @@ -0,0 +1,106 @@ +using MeterVault.Core.Normalization; + +namespace MeterVault.Core.Analysis; + +/// +/// Local-calendar arithmetic shared by the period, bucket and comparison resolvers, so all three agree on +/// what "today", "a month's end" and "this wall-clock time last year" mean in the instance zone. +/// +/// +/// Every boundary is a local calendar date turned into an instant with +/// — the same function the normalizer +/// divides months with — because a range whose edges disagree with the stamps of the divided shares would +/// re-file them into the wrong month. +/// +internal static class LocalCalendar +{ + /// + /// How far past a nonexistent or repeated wall-clock time the search for the gap's or fold's end may + /// walk. Ordinary DST shifts are an hour; the longest on record (Samoa, 30 December 2011) skipped a + /// whole day. + /// + private const int MaxGapMinutes = 48 * 60; + + /// The local calendar date an instant falls on in . + public static DateOnly DateOf(DateTimeOffset instant, TimeZoneInfo zone) => + DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(instant, zone).DateTime); + + /// The instant a local date starts, in UTC (DST gaps and folds at midnight handled). + public static DateTimeOffset Midnight(DateOnly date, TimeZoneInfo zone) => GapAttribution.LocalMidnight(date, zone); + + public static DateOnly MonthStart(DateOnly date) => new(date.Year, date.Month, 1); + + public static DateOnly MonthEnd(DateOnly date) => new(date.Year, date.Month, DateTime.DaysInMonth(date.Year, date.Month)); + + public static bool IsMonthEnd(DateOnly date) => date.Day == DateTime.DaysInMonth(date.Year, date.Month); + + /// The Monday that starts the local week containing . + public static DateOnly WeekStart(DateOnly date) => date.AddDays(-(((int)date.DayOfWeek + 6) % 7)); + + /// Whole calendar months from the month of to that of , both counted. + public static int MonthsSpanned(DateOnly first, DateOnly last) => + ((last.Year - first.Year) * 12) + last.Month - first.Month + 1; + + /// + /// The instant a local wall-clock time names (D-06). A time inside a spring-forward gap does not exist; + /// it takes the first instant that does — the transition itself, whose wall clock reads the end of the + /// gap. A time inside an autumn fold exists twice; it takes the occurrence whose offset matches + /// ("now"'s offset, so a cut-off taken in summer time maps to summer + /// time), and otherwise the first occurrence, which is the larger offset. + /// + public static DateTimeOffset InstantOf(DateTime wall, TimeZoneInfo zone, TimeSpan preferredOffset) + { + ArgumentNullException.ThrowIfNull(zone); + + var local = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified); + if (zone.IsInvalidTime(local)) + { + // Transitions fall on whole minutes, so walking a minute grid from the gap finds its end exactly. + var probe = new DateTime(local.Ticks - (local.Ticks % TimeSpan.TicksPerMinute), DateTimeKind.Unspecified); + for (var step = 0; step < MaxGapMinutes && zone.IsInvalidTime(probe); step++) + { + probe = probe.AddMinutes(1); + } + + local = probe; + } + + TimeSpan offset; + if (zone.IsAmbiguousTime(local)) + { + var offsets = zone.GetAmbiguousTimeOffsets(local); + offset = Array.IndexOf(offsets, preferredOffset) >= 0 ? preferredOffset : offsets.Max(); + } + else + { + offset = zone.GetUtcOffset(local); + } + + return new DateTimeOffset(local, offset).ToUniversalTime(); + } + + /// + /// The first wall-clock time after that is no longer repeated: the end of the + /// autumn fold lies in (03:00 for Berlin's 02:00–03:00). Returns + /// itself when it is not ambiguous. + /// + public static DateTime FoldEnd(DateTime wall, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + + var local = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified); + if (!zone.IsAmbiguousTime(local)) + { + return local; + } + + // Folds, like gaps, begin and end on whole minutes. + var probe = new DateTime(local.Ticks - (local.Ticks % TimeSpan.TicksPerMinute), DateTimeKind.Unspecified); + for (var step = 0; step < MaxGapMinutes && zone.IsAmbiguousTime(probe); step++) + { + probe = probe.AddMinutes(1); + } + + return probe; + } +} diff --git a/src/Core/Analysis/Time/PeriodBucket.cs b/src/Core/Analysis/Time/PeriodBucket.cs new file mode 100644 index 0000000..23be0ab --- /dev/null +++ b/src/Core/Analysis/Time/PeriodBucket.cs @@ -0,0 +1,60 @@ +namespace MeterVault.Core.Analysis; + +/// +/// A whole period as one bucket, so its total gets a status by the same coverage rules its buckets get (D-14, +/// D-27: "a period total is … partial when that coverage is smaller than the period"). +/// +/// +/// +/// The status of a total must not depend on how the chart is cut. A monthly import is unresolved for every day of +/// January, yet it resolves January as a whole; so the period is evaluated as a bucket of the coarsest calendar +/// unit its named range is aligned to — a year for whole years, a month for whole months, a week for whole +/// Monday-to-Sunday weeks, a day otherwise. Alignment is read from the range the period names +/// (), so a year to date is a year, cut at now, like its buckets are. +/// +/// +/// The bucket runs from the period's to its : to +/// now for a to-date period, which is what the up-to-date tolerance (A-04) needs to see. +/// +/// +public static class PeriodBucket +{ + /// The period as one bucket; empty (From == To) for a period with nothing to read. + public static AnalysisBucket Of(ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(period); + + var first = period.FirstDay; + var last = period.EffectiveLastDay(); + var endDay = last >= first ? last.AddDays(1) : first; + var nominalLast = period.NominalLastDay(); + var nominalEnd = nominalLast >= first ? nominalLast.AddDays(1) : endDay; + var size = nominalLast >= first ? AlignedSize(first, nominalLast) : BucketSize.Day; + + return new AnalysisBucket(first, endDay, period.From, period.To, size, nominalEnd > endDay ? nominalEnd : null); + } + + /// + /// The coarsest calendar unit the inclusive local range [first, last] is made of whole units of: year, + /// month, week (Monday start, D-05) or day. + /// + public static BucketSize AlignedSize(DateOnly first, DateOnly last) + { + if (last < first) + { + return BucketSize.Day; + } + + if (first is { Month: 1, Day: 1 } && last is { Month: 12, Day: 31 }) + { + return BucketSize.Year; + } + + if (first.Day == 1 && LocalCalendar.IsMonthEnd(last)) + { + return BucketSize.Month; + } + + return LocalCalendar.WeekStart(first) == first && last.DayOfWeek == DayOfWeek.Sunday ? BucketSize.Week : BucketSize.Day; + } +} diff --git a/src/Core/Analysis/Time/PeriodResolver.cs b/src/Core/Analysis/Time/PeriodResolver.cs new file mode 100644 index 0000000..1942136 --- /dev/null +++ b/src/Core/Analysis/Time/PeriodResolver.cs @@ -0,0 +1,269 @@ +namespace MeterVault.Core.Analysis; + +/// +/// Turns a period preset or a custom date range into one (D-02, D-03, D-04): +/// resolved once, against a captured "now" and the instance zone, so every figure on a page — quantity, +/// cost, comparison, export — reads exactly the same bounds. +/// +/// +/// +/// The resolver is pure: callers read the clock once per request (D-01) and pass the instant in. Local +/// dates become instants only through local midnight in the instance zone, never UTC midnight — a range +/// that starts at 00:00 UTC in Berlin would file the first hour of the month's divided consumption under +/// the month before. +/// +/// +/// How the fields read, by case: +/// +/// Complete (last month, previous year, a custom range before today): To is the local +/// midnight after LastDay. +/// To date (month/year to date, last 12/24 months, a range ending today): LastDay is +/// today, To is now — actual figures stop at now (D-04). +/// Reaching past today (a custom range, ): +/// LastDay keeps the requested day so the selection round-trips to the date pickers and shows as +/// asked, while To is still now. Only this case has a LastDay after the local date of +/// To. +/// Entirely in the future (): the dates are the request, and +/// To equals From, so a reader that forgets to check cannot count recorded-after-now rows as +/// actuals. +/// Just begun (a to-date period at the very instant it starts, e.g. month to date at 00:00 on +/// the 1st): From == To == Now, yet the period has started — it is to date, today is its one +/// (empty) day, and it plans and compares like "last 12 months" does at that instant, whose current month +/// is then empty. Only tells this apart from a future range; the contract's +/// (From >= Now) holds for both. +/// No history ( without availability): see +/// . +/// +/// +/// +public static class PeriodResolver +{ + /// + /// The earliest date a custom range or URL token may name. The reference data starts in 1997; nothing + /// before 1900 is plausible metering history, and a bound keeps date arithmetic far from the edges of + /// . + /// + public static readonly DateOnly MinSupportedDate = new(1900, 1, 1); + + /// + /// The latest date a custom range or URL token may name. Chosen so that no accepted range spans more + /// than 400 years: year buckets then always fit the 400-point limit and Auto never has to refuse. + /// + public static readonly DateOnly MaxSupportedDate = new(2299, 12, 31); + + /// Resolves a preset (or custom range) against a captured "now" in the instance zone. + /// The period to resolve. + /// First local day of a custom range (inclusive); only read for . + /// Last local day of a custom range (inclusive); only read for . + /// The instant captured once for this request. + /// The instance zone (MeterVault__TimeZone). + /// + /// First local day of available data (D-19); only read for . A day + /// before is read as that date. + /// + /// + /// Last local day of available data; only read for . Null means + /// "up to now"; a day after today is read as today. + /// + /// A custom range that fails . + public static ResolvedPeriod Resolve( + PeriodPreset preset, + DateOnly? customFirst, + DateOnly? customLast, + DateTimeOffset now, + TimeZoneInfo zone, + DateOnly? availableFirst = null, + DateOnly? availableLast = null) + { + ArgumentNullException.ThrowIfNull(zone); + + var nowUtc = now.ToUniversalTime(); + var today = LocalDate(nowUtc, zone); + var monthStart = LocalCalendar.MonthStart(today); + + return preset switch + { + PeriodPreset.MonthToDate => Span(preset, monthStart, today, nowUtc, today, zone), + PeriodPreset.LastMonth => Span(preset, monthStart.AddMonths(-1), monthStart.AddDays(-1), nowUtc, today, zone), + PeriodPreset.YearToDate => Span(preset, new DateOnly(today.Year, 1, 1), today, nowUtc, today, zone), + PeriodPreset.PreviousYear => Span(preset, new DateOnly(today.Year - 1, 1, 1), new DateOnly(today.Year - 1, 12, 31), nowUtc, today, zone), + + // Twelve calendar months ending with the current, partial one: exactly 12 buckets, the last + // stopping at now — never a 13th or a future month. + PeriodPreset.Last12Months => Span(preset, monthStart.AddMonths(-11), today, nowUtc, today, zone), + PeriodPreset.Last24Months => Span(preset, monthStart.AddMonths(-23), today, nowUtc, today, zone), + PeriodPreset.AllHistory => All(availableFirst, availableLast, nowUtc, today, zone), + PeriodPreset.Custom => Custom(customFirst, customLast, nowUtc, today, zone), + _ => throw new ArgumentOutOfRangeException(nameof(preset), preset, "Unknown period preset."), + }; + } + + /// + /// True when both dates are present, in order and inside + /// [, ]. Pages check this before resolving a + /// custom range, and fall back to their default with a notice when it fails (D-02). + /// + public static bool IsValidCustomRange(DateOnly? first, DateOnly? last) => + first is { } f && last is { } l && f <= l && f >= MinSupportedDate && l <= MaxSupportedDate; + + /// The local calendar date of in — "today" for "now". + public static DateOnly LocalDate(DateTimeOffset instant, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + + return LocalCalendar.DateOf(instant, zone); + } + + /// + /// True for the "no history" result: asked of a scope with no + /// available data. It is an empty range anchored at the start of today — LastDay is the day + /// before FirstDay and From == To — so loops over its days or buckets do nothing. At the + /// very instant of local midnight holds for it as well; + /// "no history" is the true answer, and does not claim it. + /// + public static bool HasNoHistory(this ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(period); + + return period.LastDay < period.FirstDay; + } + + /// + /// True when the whole requested range lies after now, so nothing of it has happened yet (D-04): the + /// page reports "not yet occurred", plans no buckets and compares nothing. + /// + /// + /// Narrower than the contract's (From >= Now), which + /// also holds for a to-date period at the instant it begins and for the no-history result at local + /// midnight. A to-date period has started even with nothing elapsed: month to date at 00:00 on the 1st + /// has today as an empty day, exactly like the current month of "last 12 months" at that instant, so the + /// two presets never disagree about whether the month exists. Use this rather than + /// until the contract adopts the same rule. + /// + public static bool HasNotStarted(this ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(period); + + return !period.IsToDate && !period.HasNoHistory() && period.From >= period.Now; + } + + /// + /// The last local day that actual figures reach: today for a period cut at now, + /// for a complete one, and the day before + /// — an empty range — for one that has not started or has no + /// history. This is the date to display next to a preset ("1 – 19 Sep"), and the last rollup day a + /// reader may sum as actuals: the days of a range that has not started hold nothing but rows recorded + /// after now (D-04). + /// + public static DateOnly EffectiveLastDay(this ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(period); + + if (period.HasNoHistory()) + { + return period.LastDay; + } + + if (period.HasNotStarted()) + { + return period.FirstDay.AddDays(-1); + } + + return period.IsToDate ? LocalCalendar.DateOf(period.Now, period.Zone) : period.LastDay; + } + + /// + /// The last day of the range the period names, beyond the cut at now: the end of the month for + /// month to date, 31 December for year to date, the end of the current month for the last 12/24 months, + /// and otherwise (a custom range's requested last day; for all + /// history, the last day of the data). Comparisons align on it (a month to date is a month), Auto picks + /// its bucket size from it (A-06), and a bucket cut at now knows its whole unit by it. + /// + public static DateOnly NominalLastDay(this ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(period); + + return period.Preset switch + { + PeriodPreset.MonthToDate => LocalCalendar.MonthEnd(period.FirstDay), + PeriodPreset.YearToDate => new DateOnly(period.FirstDay.Year, 12, 31), + PeriodPreset.Last12Months or PeriodPreset.Last24Months => LocalCalendar.MonthEnd(period.LastDay), + _ => period.LastDay, + }; + } + + /// + /// The instant the named range ends — the local midnight after — or null for + /// , which is open-ended. The "recorded after now" block (D-04) looks + /// for rows whose interval ends in [now, NominalEnd); for all history that has no upper bound, so a + /// row stamped years ahead (a device with a wrong clock) is still reported rather than silently dropped. + /// + public static DateTimeOffset? NominalEnd(this ResolvedPeriod period) + { + ArgumentNullException.ThrowIfNull(period); + + return period.Preset == PeriodPreset.AllHistory + ? null + : LocalCalendar.Midnight(period.NominalLastDay().AddDays(1), period.Zone); + } + + private static ResolvedPeriod All(DateOnly? availableFirst, DateOnly? availableLast, DateTimeOffset now, DateOnly today, TimeZoneInfo zone) + { + // "All" spans the data the scope actually has (D-19), not a fixed century. Availability comes from + // the data, not from a validated URL token, so it is clamped to the supported dates and to today: a + // stray reading dated 0206 must neither throw from local-midnight arithmetic near year 1 nor stretch + // the range over two millennia, and actual figures stop at now (D-04). A last day of null means the + // data runs up to now. + if (availableFirst is not { } requestedFirst) + { + return NoHistory(now, today, zone); + } + + var first = requestedFirst < MinSupportedDate ? MinSupportedDate : requestedFirst; + var last = availableLast is { } requestedLast && requestedLast < today ? requestedLast : today; + return last < first ? NoHistory(now, today, zone) : Span(PeriodPreset.AllHistory, first, last, now, today, zone); + } + + private static ResolvedPeriod NoHistory(DateTimeOffset now, DateOnly today, TimeZoneInfo zone) + { + var startOfToday = LocalCalendar.Midnight(today, zone); + return new ResolvedPeriod( + PeriodPreset.AllHistory, today, today.AddDays(-1), startOfToday, startOfToday, now, + IsToDate: false, ExtendsPastNow: false, zone); + } + + private static ResolvedPeriod Custom(DateOnly? first, DateOnly? last, DateTimeOffset now, DateOnly today, TimeZoneInfo zone) + { + if (!IsValidCustomRange(first, last)) + { + throw new ArgumentException( + $"A custom period needs a first and last day in order, between {MinSupportedDate:yyyy-MM-dd} and {MaxSupportedDate:yyyy-MM-dd}.", + nameof(first)); + } + + return Span(PeriodPreset.Custom, first!.Value, last!.Value, now, today, zone); + } + + /// + /// The common shape of every non-empty period: complete when it ends before today, cut at now when it + /// reaches today or beyond, and empty-at-its-start when it has not begun. + /// + private static ResolvedPeriod Span(PeriodPreset preset, DateOnly first, DateOnly last, DateTimeOffset now, DateOnly today, TimeZoneInfo zone) + { + var from = LocalCalendar.Midnight(first, zone); + + if (last < today) + { + return new ResolvedPeriod( + preset, first, last, from, LocalCalendar.Midnight(last.AddDays(1), zone), now, + IsToDate: false, ExtendsPastNow: false, zone); + } + + if (first > today) + { + return new ResolvedPeriod(preset, first, last, from, from, now, IsToDate: false, ExtendsPastNow: true, zone); + } + + return new ResolvedPeriod(preset, first, last, from, now, now, IsToDate: true, ExtendsPastNow: last > today, zone); + } +} diff --git a/src/Core/Analysis/Totals/CategoryCover.cs b/src/Core/Analysis/Totals/CategoryCover.cs new file mode 100644 index 0000000..af31c69 --- /dev/null +++ b/src/Core/Analysis/Totals/CategoryCover.cs @@ -0,0 +1,162 @@ +namespace MeterVault.Core.Analysis.Totals; + +/// +/// The non-overlapping cover of one cost category's members (D-42): which of them are priced, which are +/// credited, and which are only there for analysis. +/// +/// +/// Price , not the id lists. A category that lies inside the bill takes every line exactly as the +/// full bill prices it — the grid import with a separately billed heat pump deducted, the heat pump at its own price — +/// even when the heat pump is not a member: it is then its own line elsewhere (another category, or Uncategorized), +/// and that is what makes the composition add up to the bill. A category outside the bill is a view, priced as its +/// own restricted bill. +/// +/// The category. +/// The members the cover was computed from (meter members plus expanded type members), ascending. +/// Cover meters priced at the normal unit price (the lines). +/// +/// Cover meters billed at their own meter-scoped price (D-35), reported as the full bill reports them when the +/// category lies inside the bill — also when the category holds nothing but the subsection. +/// +/// Export members whose feed-in credit belongs to the category. +/// Members that add nothing to the cost: subsections, supply views, generation, virtual views. +/// Cover meters the full bill does not price or credit (e.g. a subsection priced on its own). +/// The members' classification within the category, for explaining the cover. +/// The category's priceable lines, with the deductions each takes (see remarks). +public sealed record CategoryCoverResult( + int CategoryId, + IReadOnlyList MemberIds, + IReadOnlyList BilledMeterIds, + IReadOnlyList SeparatelyBilled, + IReadOnlyList FeedInMeterIds, + IReadOnlyList AnalysisOnlyMeterIds, + IReadOnlyList OutsideBillMeterIds, + IReadOnlyDictionary Meters, + IReadOnlyList Lines) +{ + /// Every meter that gives the category a cost line: billed, separately billed or credited. + public IReadOnlyList CoverMeterIds => + [.. BilledMeterIds.Concat(SeparatelyBilled.Select(s => s.MeterId)).Concat(FeedInMeterIds).Distinct().Order()]; + + /// + /// The category prices something the bill does not (a subsection, household use instead of grid import), so + /// its cost is a view on the bill rather than a slice of it and stays out of the composition. + /// + public bool LiesOutsideBill => OutsideBillMeterIds.Count > 0; +} + +/// Two categories whose covers share bill lines, so their costs cannot both be slices of the bill. +public sealed record CategoryOverlap(int CategoryId, int OtherCategoryId, IReadOnlyList SharedMeterIds); + +/// +/// How a set of categories relates to the bill (D-42): the disjoint ones form the cost composition together with +/// (and the standing-charge rows the costing adds); the overlapping views are +/// shown on their own and never summed into it. +/// +/// Categories whose cover lies inside the bill and shares no line with another. +/// Categories that lie outside the bill or share a line with another category. +/// Every pair of categories sharing a line, with the shared meters. +/// Bill lines no disjoint category covers — the "Uncategorized" slice. +public sealed record CategoryOverlapReport( + IReadOnlyList DisjointCategoryIds, + IReadOnlyList OverlappingViewIds, + IReadOnlyList Overlaps, + IReadOnlyList UncategorizedMeterIds); + +/// +/// Category costs from the same cover as the bill (D-42). +/// +/// +/// +/// Pricing every member of a category adds up overlapping meters exactly like pricing every meter of a type does: +/// the seeded Strom category {Haus, Netz, Auto, Solar 1, Solar 2} would charge household use, grid import and the +/// car on top of each other. So a category's cost is the bill algorithm run on its members only — the Strom +/// category bills Netz, as the sheet does, while a category holding only the car bills the car. +/// +/// +/// A category only counts as a slice of the bill when everything it prices is also priced by the bill, and no +/// other category prices the same line; a disjoint category then takes each line as the bill prices it — its +/// with the bill's deductions (a grid import already reduced by a separately billed heat pump, +/// say) — which is what makes the composition reconcile. Everything else is an overlapping view: useful, shown, +/// never added to the others. +/// +/// +/// The restricted run keeps the instance's topology: a heat-pump meter is still a subsection of the house when the +/// basement meter between them is not a member, and still separately billed when the house is not a member. +/// +/// +public static class CategoryCover +{ + /// + /// The cover of a category with (type members already expanded to their meters; + /// unknown ids are ignored), judged against the full bill in . + /// + public static CategoryCoverResult Compute(TotalsClassification full, int categoryId, IEnumerable memberIds) + { + ArgumentNullException.ThrowIfNull(full); + ArgumentNullException.ThrowIfNull(memberIds); + + var members = memberIds.Where(full.Graph.Meters.ContainsKey).Distinct().Order().ToList(); + var run = TotalsRun.Execute(full.Graph, members); + var billings = run.Types.Values.Select(t => t.Billing).ToList(); + var own = billings.SelectMany(b => b.Lines).ToList(); + var cover = own.Select(l => l.MeterId).ToHashSet(); + List outside = [.. cover.Where(id => !full.IsBilled(id)).Order()]; + + // Inside the bill, every line is the bill's own line: a meter the restricted run happens to bill at the normal + // price (a heat pump alone in its category is a root there) is still the separately billed line of the bill. + IEnumerable lines = outside.Count == 0 ? own.Select(l => full.LineOf(l.MeterId)!) : own; + List ordered = [.. lines.OrderBy(l => l.Kind).ThenBy(l => l.MeterId)]; + var separately = outside.Count == 0 + ? full.Types.Values.SelectMany(t => t.Billing.SeparatelyBilled).Where(s => cover.Contains(s.MeterId)) + : billings.SelectMany(b => b.SeparatelyBilled); + + return new CategoryCoverResult( + categoryId, + members, + [.. ordered.Where(l => l.Kind == BillLineKind.UnitPrice).Select(l => l.MeterId)], + [.. separately.OrderBy(s => s.MeterId).ThenBy(s => s.SubtractFromMeterId)], + [.. ordered.Where(l => l.Kind == BillLineKind.FeedIn).Select(l => l.MeterId)], + [.. members.Where(id => !cover.Contains(id))], + outside, + run.Entries, + ordered); + } + + /// + /// Sorts categories into slices of the bill and overlapping views: a category is overlapping when it lies + /// outside the bill, or when any of its bill lines is also in another category's cover. + /// + public static CategoryOverlapReport CheckOverlap(TotalsClassification full, IEnumerable covers) + { + ArgumentNullException.ThrowIfNull(full); + ArgumentNullException.ThrowIfNull(covers); + + var list = covers.ToList(); + var overlaps = new List(); + var overlapping = new HashSet(list.Where(c => c.LiesOutsideBill).Select(c => c.CategoryId)); + for (var i = 0; i < list.Count; i++) + { + for (var j = i + 1; j < list.Count; j++) + { + var shared = list[i].CoverMeterIds.Intersect(list[j].CoverMeterIds).Order().ToList(); + if (shared.Count == 0) + { + continue; + } + + overlaps.Add(new CategoryOverlap(list[i].CategoryId, list[j].CategoryId, shared)); + overlapping.Add(list[i].CategoryId); + overlapping.Add(list[j].CategoryId); + } + } + + var disjoint = list.Where(c => !overlapping.Contains(c.CategoryId)).ToList(); + var categorized = disjoint.SelectMany(c => c.CoverMeterIds).ToHashSet(); + return new CategoryOverlapReport( + [.. disjoint.Select(c => c.CategoryId)], + [.. list.Select(c => c.CategoryId).Where(overlapping.Contains).Distinct()], + overlaps, + [.. full.BillItems.Where(id => !categorized.Contains(id)).Order()]); + } +} diff --git a/src/Core/Analysis/Totals/MeasureValues.cs b/src/Core/Analysis/Totals/MeasureValues.cs new file mode 100644 index 0000000..de941e6 --- /dev/null +++ b/src/Core/Analysis/Totals/MeasureValues.cs @@ -0,0 +1,125 @@ +namespace MeterVault.Core.Analysis.Totals; + +/// +/// Adds up the members of a measure (D-22) bucket by bucket: the value of an energy type's household use, grid +/// import, export, generation or runtime from the meters the totals policy counted in it. +/// +/// +/// +/// The members of one measure never overlap — that is what the policy is for — so their values add up. What needs +/// a rule is the status. A member that cannot say anything about the bucket spoils the total the way it spoils a +/// virtual sum: pending (being rebuilt) or invalid (a broken virtual member) wins, then unresolved (too coarse to +/// cut). A member without data makes the total partial rather than missing, because the others still measured +/// something: "3 of 4 meters" is a partial total the UI can name the missing meter for (brief §4.3), and only when +/// no member has data is the total missing. A member outside its service period is not missing — the caller hands +/// in its known zero (D-24, ). +/// +/// +/// Every issue that comes from a member carries a dependency path starting at that member, so an attention item +/// can open the meter behind it. +/// +/// +public static class MeasureValues +{ + /// Combines one bucket's member values; missing () without members. + /// Each member's id and its value for the bucket. + public static BucketValue Sum(IReadOnlyList<(int MeterId, BucketValue Value)> members) + { + ArgumentNullException.ThrowIfNull(members); + + if (members.Count == 0) + { + return BucketValue.Missing(); + } + + foreach (var status in (ReadOnlySpan)[BucketStatus.Pending, BucketStatus.Invalid, BucketStatus.Unresolved]) + { + if (First(members, status) is { } spoiling) + { + var issue = spoiling.Value.Issue != ValueIssue.None ? spoiling.Value.Issue : DefaultIssue(status); + var provenance = status == BucketStatus.Unresolved ? CombinedProvenance(members) : Provenance.None; + return new BucketValue(null, status, provenance, issue, spoiling.Value.IssueDetail, PathOf(spoiling)); + } + } + + var known = members.Where(m => m.Value.Value is not null).ToList(); + if (known.Count == 0) + { + var missing = members[0]; + return new BucketValue(null, BucketStatus.Missing, Provenance.None, Issue(missing.Value, ValueIssue.NoCoverage), null, null); + } + + var sum = known.Sum(m => m.Value.Value!.Value); + var combined = CombinedProvenance(members); + if (First(members, BucketStatus.Missing) is { } absent) + { + return new BucketValue(sum, BucketStatus.Partial, combined, ValueIssue.MissingSource, absent.Value.IssueDetail, PathOf(absent)); + } + + if (First(members, BucketStatus.Partial) is { } partial) + { + return new BucketValue(sum, BucketStatus.Partial, combined, Issue(partial.Value, ValueIssue.PartialCoverage), partial.Value.IssueDetail, PathOf(partial)); + } + + return new BucketValue(sum, BucketStatus.Available, combined); + } + + /// Combines a whole series of member values, bucket by bucket; every member needs one value per bucket. + /// Each member's id and its values, one per bucket. + /// The number of buckets. + /// A member's values do not match the bucket count. + public static IReadOnlyList SumSeries(IReadOnlyList<(int MeterId, IReadOnlyList Values)> members, int bucketCount) + { + ArgumentNullException.ThrowIfNull(members); + ArgumentOutOfRangeException.ThrowIfNegative(bucketCount); + + foreach (var (meterId, values) in members) + { + if (values.Count != bucketCount) + { + throw new ArgumentException($"Member m{meterId} has {values.Count} values for {bucketCount} buckets.", nameof(members)); + } + } + + var result = new BucketValue[bucketCount]; + for (var b = 0; b < bucketCount; b++) + { + var index = b; + result[b] = Sum([.. members.Select(m => (m.MeterId, m.Values[index]))]); + } + + return result; + } + + private static (int MeterId, BucketValue Value)? First(IReadOnlyList<(int MeterId, BucketValue Value)> members, BucketStatus status) + { + foreach (var member in members) + { + if (member.Value.Status == status) + { + return member; + } + } + + return null; + } + + private static Provenance CombinedProvenance(IReadOnlyList<(int MeterId, BucketValue Value)> members) => + members.Aggregate(Provenance.None, (all, m) => all | m.Value.Provenance); + + private static ValueIssue Issue(BucketValue value, ValueIssue fallback) => value.Issue == ValueIssue.None ? fallback : value.Issue; + + private static ValueIssue DefaultIssue(BucketStatus status) => status switch + { + BucketStatus.Pending => ValueIssue.AnalysisPending, + BucketStatus.Invalid => ValueIssue.InvalidDefinition, + BucketStatus.Unresolved => ValueIssue.CoarseResolution, + _ => ValueIssue.None, + }; + + /// The path from the member down to the cause: the member's own path when it has one, else the member. + private static IReadOnlyList PathOf((int MeterId, BucketValue Value) member) => + member.Value.DependencyPath is { Count: > 0 } path + ? path[0] == member.MeterId ? path : [member.MeterId, .. path] + : [member.MeterId]; +} diff --git a/src/Core/Analysis/Totals/TotalsGraph.cs b/src/Core/Analysis/Totals/TotalsGraph.cs new file mode 100644 index 0000000..5df2d23 --- /dev/null +++ b/src/Core/Analysis/Totals/TotalsGraph.cs @@ -0,0 +1,358 @@ +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Totals; + +/// What a meter physically measures, as far as totals care. +internal enum MeterNature +{ + Consumption, + Generation, + Runtime, + Export, + Calculated, +} + +/// +/// The configuration-level facts every totals run shares (D-22 steps 1–2): each meter's nature, the roles in +/// force per energy type, which meters are supply, and the containment edges. A category cover reruns the +/// algorithm on a subset of meters (D-42) but must keep these facts from the whole instance — a role loser stays +/// a loser, and a heat-pump meter stays a subsection of the house even when the house is not in the category. +/// +internal sealed class TotalsGraph +{ + private readonly Dictionary _nature = []; + private readonly Dictionary _declaredRole = []; + private readonly Dictionary<(int Type, MeterRole Role), List> _holders = []; + private readonly Dictionary _duplicateOf = []; + private readonly HashSet _supply = []; + private readonly Dictionary> _parents = []; + private readonly Dictionary> _linkTargets = []; + private readonly Dictionary> _assumedParents = []; + private readonly HashSet _meterScopedUnitPrice; + + private TotalsGraph(IReadOnlyDictionary meters, HashSet meterScopedUnitPrice) + { + Meters = meters; + OrderedIds = [.. meters.Keys.Order()]; + _meterScopedUnitPrice = meterScopedUnitPrice; + } + + public IReadOnlyDictionary Meters { get; } + + public IReadOnlyList OrderedIds { get; } + + /// Role problems found while resolving roles; reported by the full run only. + public IReadOnlyList RoleProblems { get; private set; } = []; + + /// Every meter of the instance. + /// Every topology link. + /// + /// Asked once per meter, here and now, and never again: the classification outlives the call (category covers + /// are computed from it later), while a caller's predicate typically closes over a scoped database context that + /// will be disposed by then. + /// + public static TotalsGraph Build( + IEnumerable meters, + IEnumerable links, + Func? hasMeterScopedUnitPrice) + { + ArgumentNullException.ThrowIfNull(meters); + ArgumentNullException.ThrowIfNull(links); + + var byId = new Dictionary(); + foreach (var meter in meters) + { + ArgumentNullException.ThrowIfNull(meter); + if (!byId.TryAdd(meter.Id, meter)) + { + throw new ArgumentException($"Meter {meter.Id} is listed twice.", nameof(meters)); + } + } + + HashSet priced = hasMeterScopedUnitPrice is null ? [] : [.. byId.Keys.Where(hasMeterScopedUnitPrice)]; + var graph = new TotalsGraph(byId, priced); + graph.ResolveNatures(); + graph.ResolveRoles(); + graph.ResolveSupply(); + graph.ResolveLinks(links); + graph.ResolveAssumedParents(); + return graph; + } + + public static bool IsCalculated(TotalsMeter meter) => meter.IsVirtual || meter.Mode == MeterMode.Virtual; + + /// + /// The measure a virtual meter's declared result belongs to. Only consumption and generation add up (A-08); a + /// net or indicator result, or none at all (a definition that is not usable), belongs to no measure. + /// + public static TotalsMeasure? VirtualMeasure(TotalsMeter meter) => meter.VirtualResultKind switch + { + QuantityKind.Consumption => TotalsMeasure.Use, + QuantityKind.Generation => TotalsMeasure.Generation, + _ => null, + }; + + /// + /// Whether two meters were in service at the same time (D-24; open ends when a date is missing). Sharing only + /// the handover day does not count: one meter is retired and its successor installed that day, and each measures + /// its own part of it. + /// + public static bool InServiceTogether(TotalsMeter a, TotalsMeter b) + { + var aFrom = a.InstalledAt ?? DateOnly.MinValue; + var aTo = a.RetiredAt ?? DateOnly.MaxValue; + var bFrom = b.InstalledAt ?? DateOnly.MinValue; + var bTo = b.RetiredAt ?? DateOnly.MaxValue; + return aFrom < bTo && bFrom < aTo; + } + + public bool InServiceTogether(int a, int b) => InServiceTogether(Meters[a], Meters[b]); + + public MeterNature NatureOf(int id) => _nature[id]; + + /// The role this meter plays (as holder or as duplicate), or null. + public MeterRole? DeclaredRoleOf(int id) => _declaredRole.TryGetValue(id, out var role) ? role : null; + + /// + /// The meters holding in , ascending. Several only when the + /// role passed from one meter record to the next (A-07); their service periods never overlap. + /// + public IReadOnlyList HoldersOf(int energyTypeId, MeterRole role) => + _holders.TryGetValue((energyTypeId, role), out var holders) ? holders : []; + + /// For a meter declaring a role another meter holds at the same time: that holder (the lowest id). + public int? DuplicateOf(int id) => _duplicateOf.TryGetValue(id, out var holder) ? holder : null; + + public bool IsSupply(int id) => _supply.Contains(id); + + /// Direct containment parents (link-based), ascending. Empty for meters that cannot be contained. + public IReadOnlyList ParentsOf(int id) => _parents.GetValueOrDefault(id) ?? []; + + /// Every same-type link out of a meter, whatever it means; used for reachability only. + public IReadOnlyList LinkTargetsOf(int id) => _linkTargets.GetValueOrDefault(id) ?? []; + + /// + /// The total_load meters an unlinked consumption meter is taken to be part of, in the full topology — so a + /// category that leaves the total_load meter out still knows the meter is inside something. + /// + public IReadOnlyList AssumedParentsOf(int id) => _assumedParents.GetValueOrDefault(id) ?? []; + + public bool HasMeterScopedUnitPrice(int id) => _meterScopedUnitPrice.Contains(id); + + /// A consumption meter that can sit inside another and count in household use. + public bool IsContainableConsumer(int id) => _nature[id] == MeterNature.Consumption && !_supply.Contains(id); + + /// + /// The measure a meter's energy belongs to by what it measures, whatever the overrides: its role's measure, else + /// its nature's (a virtual meter: its declared result's). Null for a meter that belongs to none. + /// + public TotalsMeasure? NaturalMeasureOf(int id) => DeclaredRoleOf(id) switch + { + MeterRole.TotalLoad => TotalsMeasure.Use, + MeterRole.GridImport => TotalsMeasure.GridImport, + MeterRole.GridExport => TotalsMeasure.Export, + _ => _nature[id] switch + { + MeterNature.Consumption => TotalsMeasure.Use, + MeterNature.Generation => TotalsMeasure.Generation, + MeterNature.Export => TotalsMeasure.Export, + MeterNature.Runtime => TotalsMeasure.Runtime, + _ => VirtualMeasure(Meters[id]), + }, + }; + + /// All ancestors of over , cycle-safe. + public static HashSet Closure(int id, Func> parentsOf) + { + var seen = new HashSet(); + var queue = new Queue(parentsOf(id)); + while (queue.Count > 0) + { + var next = queue.Dequeue(); + if (!seen.Add(next)) + { + continue; + } + + foreach (var parent in parentsOf(next)) + { + queue.Enqueue(parent); + } + } + + return seen; + } + + private void ResolveNatures() + { + foreach (var meter in Meters.Values) + { + _nature[meter.Id] = meter switch + { + _ when IsCalculated(meter) => MeterNature.Calculated, + { Mode: MeterMode.GenerationCounter } or { Kind: QuantityKind.Generation } => MeterNature.Generation, + { Mode: MeterMode.RuntimeCounter } or { Kind: QuantityKind.Runtime } => MeterNature.Runtime, + { Kind: QuantityKind.Export } => MeterNature.Export, + _ => MeterNature.Consumption, + }; + } + } + + /// + /// Decides which roles are in force. The role is the effective one (), + /// so only a mode that may hold it carries it, and a virtual meter never does (A-07); anything else a caller + /// passes is ignored and reported. A role is unique per type among meters in service at the same time (D-21, + /// A-07): the meter editor enforces that on save (MeterRoleAssignment moves the role from the holder in service), but + /// a backup, the API or an older instance can still hold duplicates, and the + /// answer must not depend on load order — so the lowest id keeps it and the rest are reported. A retired meter + /// keeps its role next to its successor, so the history before the replacement still counts and is still billed. + /// A physical meter reporting export declares grid_export even without the role: an export register must never be + /// read as consumption. + /// + private void ResolveRoles() + { + var problems = new List(); + foreach (var id in OrderedIds) + { + var meter = Meters[id]; + var role = meter.Role; + if (role is { } given && (IsCalculated(meter) || !MeterRoleRules.IsAllowed(given, meter.Mode))) + { + problems.Add(new TotalsProblem(TotalsProblemKind.RoleNotApplicable, id, Role: given)); + role = null; + } + + if (role is null && _nature[id] == MeterNature.Export) + { + role = MeterRole.GridExport; + } + + if (role is not { } held) + { + continue; + } + + _declaredRole[id] = held; + if (!_holders.TryGetValue((meter.EnergyTypeId, held), out var holders)) + { + _holders[(meter.EnergyTypeId, held)] = holders = []; + } + + var clash = holders.Where(h => InServiceTogether(Meters[h], meter)).Select(h => (int?)h).FirstOrDefault(); + if (clash is { } holder) + { + _duplicateOf[id] = holder; + problems.Add(new TotalsProblem(TotalsProblemKind.DuplicateRole, id, holder, held)); + } + else + { + holders.Add(id); + } + } + + RoleProblems = problems; + } + + /// + /// D-22 step 1: grid import and export (declared, even by a role loser — the register still measures the + /// grid), generation counters, and generation-kind virtual meters. Their outgoing links are supply edges. + /// + private void ResolveSupply() + { + foreach (var meter in Meters.Values) + { + var supply = DeclaredRoleOf(meter.Id) is MeterRole.GridImport or MeterRole.GridExport + || _nature[meter.Id] == MeterNature.Generation + || (_nature[meter.Id] == MeterNature.Calculated && meter.VirtualResultKind == QuantityKind.Generation); + if (supply) + { + _supply.Add(meter.Id); + } + } + } + + /// + /// D-22 step 2: a link is containment only when a physical, consumption-kind, non-supply meter feeds another + /// such meter. A supply edge (grid → house, PV → house) never makes its target a subsection — the house is not + /// part of the grid meter's measure, it is what the grid supplies. Generation counters are the one kind that + /// nests within itself (an inverter meter above its strings), which is what makes "generation roots" mean + /// anything. Virtual meters are never contained: their value comes from their formula, not their links. + /// + private void ResolveLinks(IEnumerable links) + { + var parents = new Dictionary>(); + var targets = new Dictionary>(); + foreach (var link in links) + { + ArgumentNullException.ThrowIfNull(link); + if (link.FromId == link.ToId + || !Meters.TryGetValue(link.FromId, out var from) + || !Meters.TryGetValue(link.ToId, out var to) + || from.EnergyTypeId != to.EnergyTypeId) + { + continue; + } + + Add(targets, link.FromId, link.ToId); + + var consumption = IsContainableConsumer(link.FromId) && IsContainableConsumer(link.ToId); + var generation = _nature[link.FromId] == MeterNature.Generation && _nature[link.ToId] == MeterNature.Generation; + if (consumption || generation) + { + Add(parents, link.ToId, link.FromId); + } + } + + foreach (var (id, set) in parents) + { + _parents[id] = [.. set]; + } + + foreach (var (id, set) in targets) + { + _linkTargets[id] = [.. set]; + } + + static void Add(Dictionary> map, int key, int value) + { + if (!map.TryGetValue(key, out var set)) + { + map[key] = set = []; + } + + set.Add(value); + } + } + + /// + /// A consumption meter with nothing above it is part of the total_load meters in service during its own service + /// period — "total" means every load of the type — unless it sits above one of them. A meter from a time before + /// any total_load meter was measured by none, so it stays a root of its own. + /// + private void ResolveAssumedParents() + { + foreach (var id in OrderedIds) + { + if (TotalLoadsAround(id, HoldersOf(Meters[id].EnergyTypeId, MeterRole.TotalLoad), ParentsOf) is { Count: > 0 } loads) + { + _assumedParents[id] = loads; + } + } + } + + /// + /// The total_load meters among that is assumed to be inside: + /// those in service with it, when it is an unlinked plain consumer that is not above any of them. Empty otherwise. + /// + public IReadOnlyList TotalLoadsAround(int id, IReadOnlyList totalLoads, Func> parentsOf) + { + if (!IsContainableConsumer(id) || DeclaredRoleOf(id) is not null || parentsOf(id).Any()) + { + return []; + } + + var around = totalLoads.Where(load => load != id && InServiceTogether(load, id)).ToList(); + return around.Any(load => Closure(load, parentsOf).Contains(id)) ? [] : around; + } +} diff --git a/src/Core/Analysis/Totals/TotalsInputs.cs b/src/Core/Analysis/Totals/TotalsInputs.cs new file mode 100644 index 0000000..bc6df37 --- /dev/null +++ b/src/Core/Analysis/Totals/TotalsInputs.cs @@ -0,0 +1,118 @@ +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Totals; + +/// +/// A meter's explicit say in the per-type totals (D-23), stored as totals in . +/// +public enum TotalsOverride +{ + /// The topology decides (D-22). The default, and what every existing meter reads as. + Auto, + + /// + /// Count this meter even though the topology would not. On a virtual pure sum it replaces its sources in + /// that measure; on anything that would overlap a meter already counted it is refused. + /// + Always, + + /// Remove this meter from every measure it would otherwise join. + Never, +} + +/// +/// The stored tokens of . The meta blob is user-editable JSON (backups, the +/// REST API), so anything unrecognised reads as rather than failing a page. +/// +public static class TotalsOverrideTokens +{ + /// The key under which the override lives in . + public const string MetaKey = "totals"; + + public const string Auto = "auto"; + + public const string Always = "always"; + + public const string Never = "never"; + + public static TotalsOverride Parse(string? token) + { + var trimmed = token?.Trim(); + if (string.Equals(trimmed, Always, StringComparison.OrdinalIgnoreCase)) + { + return TotalsOverride.Always; + } + + return string.Equals(trimmed, Never, StringComparison.OrdinalIgnoreCase) ? TotalsOverride.Never : TotalsOverride.Auto; + } + + public static string ToToken(TotalsOverride value) => value switch + { + TotalsOverride.Always => Always, + TotalsOverride.Never => Never, + _ => Auto, + }; + + /// The override a meter's meta JSON declares; when absent or malformed. + public static TotalsOverride FromMeta(string? meta) => Parse(MeterMeta.ReadString(meta, MetaKey)); +} + +/// +/// What the totals policy needs to know about one meter. The policy is pure: the caller resolves the meter's +/// effective role, normalized quantity (D-20) and, for a virtual meter, its validated definition (D-25/D-26) +/// before asking. +/// +/// The meter id. +/// Display name; carried for callers, never interpreted (nothing is decided by name). +/// Totals are per energy type; links across types are ignored. +/// Measurement mode. and +/// decide supply and runtime even when says otherwise. +/// +/// The role the meter plays: , never the raw token (A-07). Parsing is +/// case-insensitive and a mode that may not hold a role has none, so the one reading of a stored role lives in +/// . A role a meter cannot hold (only possible when a caller skips +/// , e.g. passes a role on a virtual meter) is ignored and reported as +/// , so it can never put a tank on the bill. +/// +/// The normalized quantity kind of a physical meter (D-20). Ignored for a virtual meter, which +/// counts only through its declared . +/// The normalized unit (D-20). Measures group by and never add across +/// units; a separately billed submeter must convert into the unit of the meter it is taken out of (D-35). +/// A calculation over other meters rather than a device (also inferred from ). +/// The meter's totals override (D-23). +/// Start of the service period (D-24), inclusive; open when null. +/// +/// End of the service period (D-24), inclusive; open when null. The reader contributes a known zero outside the +/// period, so a retired meter keeps its place and its history keeps counting (D-22 step 5). The classification reads +/// the period only where meters succeed each other (A-07): two holders of one role whose periods overlap are +/// duplicates, while a meter record replaced by a new one keeps its role next to its successor; and an unlinked +/// consumer is taken to be inside only the total_load meters in service during its own period. +/// +/// +/// A virtual meter's dependencies expanded to physical meters, with multiplicity: A + A, or +/// (m1 + m2) + (m2 + m3) through nested sums, lists the repeated meter twice, so the policy can see that the +/// sum counts it twice. Empty for a physical meter. +/// +/// The formula is m1 + m2 + … with no constant at every nesting level +/// (a nested source is itself a pure sum), which is what makes replacing the sources clean. +/// The declared result kind of a virtual meter (A-08: consumption, generation, net or +/// indicator), or null when its definition is not usable — such a meter is never counted. +public sealed record TotalsMeter( + int Id, + string Name, + int EnergyTypeId, + MeterMode Mode, + MeterRole? Role, + QuantityKind Kind, + string Unit, + bool IsVirtual, + TotalsOverride Override, + DateOnly? InstalledAt, + DateOnly? RetiredAt, + IReadOnlyList VirtualSources, + bool VirtualIsPureSum, + QuantityKind? VirtualResultKind); + +/// A topology edge (): sits downstream of . +public sealed record TotalsLink(int FromId, int ToId); diff --git a/src/Core/Analysis/Totals/TotalsModels.cs b/src/Core/Analysis/Totals/TotalsModels.cs new file mode 100644 index 0000000..cd47614 --- /dev/null +++ b/src/Core/Analysis/Totals/TotalsModels.cs @@ -0,0 +1,363 @@ +using MeterVault.Core.Analysis.Quantities; + +namespace MeterVault.Core.Analysis.Totals; + +/// +/// The quantities an energy type reports side by side (D-22 step 3). They answer different questions — what +/// the household used, what came from the grid, what went back, what was generated, how long a burner ran — +/// so they are never added to each other, and within one measure never across units. +/// +public enum TotalsMeasure +{ + /// Household use: the total_load meter, or else the consumption roots. + Use, + + /// What the grid supplied (the grid_import meter). + GridImport, + + /// What went back to the grid (the grid_export meter). Never consumption. + Export, + + /// What the generators produced (the generation roots). + Generation, + + /// Operating time (runtime meters), or the volume a fixed burner rate turns it into. + Runtime, +} + +/// Where one meter ends up in its energy type's totals — the answer to "does this meter count, and why". +public enum MeterTotalsClass +{ + /// Counted in . + Use, + + /// A subsection of a counted meter: shown, never added on top of its parent. + Breakdown, + + /// Counted in . + GridImport, + + /// Counted in . + Export, + + /// Counted in . + Generation, + + /// Counted in . + Runtime, + + /// A virtual meter: a view over meters that are already counted, analysable but never added. + AnalysisOnly, + + /// Kept out of every measure by a , or replaced by an elsewhere. + ExcludedByOverride, + + /// Counted only because of its , possibly in place of its sources. + IncludedByOverride, + + /// + /// The configuration contradicts itself around this meter (a role another meter holds, or a meter that + /// contains the total load), so counting it would overlap. Shown with a problem until resolved. + /// + NotCounted, +} + +/// Why a meter got its ; a code the UI localizes. +public enum MeterTotalsReason +{ + /// Holds the type's total_load role, so it alone is the household use. + TotalLoadRole, + + /// + /// A consumption meter with nothing above it, and no total_load meter in service during its own service period + /// (usually: a type without a total_load meter). + /// + ConsumptionRoot, + + /// Holds the type's grid_import role. + GridImportRole, + + /// Holds the type's grid_export role (or reports export). + GridExportRole, + + /// A generation meter with no generation meter above it. + GenerationMeter, + + /// A runtime meter; they are all counted. + RuntimeMeter, + + /// Linked below a counted-kind meter of its own kind; names it. + ContainedByLink, + + /// + /// Not linked anywhere, but a total_load meter, which by definition measures every load, was in service during its + /// service period — so it is taken as part of it. lists those total_load + /// meters (one, unless the role passed between meter records meanwhile), + /// names the first. Only an assumption: it never makes the meter a separately billed subsection (D-35 needs a link). + /// + AssumedInsideTotalLoad, + + /// Sits above the total_load meter in the topology. names it. + ContainsTotalLoad, + + /// + /// Holds a role another meter of the type holds over an overlapping service period; the lowest id keeps it + /// (). Successive holders — a retired meter and its replacement — are + /// not duplicates (A-07). + /// + DuplicateRole, + + /// A virtual meter without a role: analysis only unless set to . + VirtualView, + + /// Its own override is . + OverrideNever, + + /// A virtual meter set to counts in its place (). + CoveredByOverride, + + /// Its own override is . + OverrideAlways, +} + +/// Why an cannot be applied (D-23). +public enum TotalsConflictReason +{ + /// A meter already counted in the same measure overlaps it (an ancestor, a subsection, or a shared source). + OverlapsCountedMeter, + + /// + /// The virtual meter's own sources overlap each other — a meter and its subsection, or one meter listed twice + /// (A + A, or nested sums sharing a meter) — so its sum double-counts. + /// + SourcesOverlap, + + /// Only a pure sum of sources can stand in for them; any other formula would change the total's meaning. + NotAPureSum, + + /// + /// The result kind belongs to no measure (net, indicator, or undeclared because the definition is not usable), so + /// it cannot be counted. + /// + NotAdditive, + + /// The meter declares a role another meter of the type holds; resolve the role first. + RoleConflict, + + /// The change would force another meter's out (Validate only). + DisplacesOverride, + + /// + /// A source of the virtual meter belongs to another energy type. Totals and bills are per type, so counting the + /// sum here would count that source's energy in two types. + /// + SourceInOtherEnergyType, + + /// + /// A source of the virtual meter counts in another measure than the sum would (a grid import, an export or a + /// generator summed as consumption, say), or is unknown. Replacement only works within one measure; anywhere else + /// the sum adds energy that measure already holds (grid energy is inside the house meter) or mixes measures. + /// + SourceInOtherMeasure, +} + +/// An override that cannot be applied, naming the meter it collides with (a meter id, never prose). +public sealed record TotalsConflict(TotalsConflictReason Reason, int? OtherMeterId); + +/// The verdict of for saving on a meter. +public sealed record TotalsOverrideCheck(int MeterId, TotalsOverride Requested, TotalsConflict? Conflict) +{ + public bool IsAllowed => Conflict is null; +} + +/// One meter's place in its type's totals. +/// The meter. +/// Its energy type. +/// Where it ends up after overrides. +/// Why, as a localizable code. +/// Where the topology alone puts it (before any override), so the UI can say what an override changed. +/// The measure it counts in, or null when it counts in none. +/// Its containment parents (for an assumed breakdown, the total_load meters in service with it), ascending. Kept for excluded meters too. +/// For : the sources it replaced in its measure. +/// The meter the reason refers to: the covering virtual, the role holder, or the total_load meter. +/// An stored on this meter that could not be applied, with why. +public sealed record MeterTotalsEntry( + int MeterId, + int EnergyTypeId, + MeterTotalsClass Class, + MeterTotalsReason Reason, + MeterTotalsClass Natural, + TotalsMeasure? Measure, + IReadOnlyList ParentIds, + IReadOnlyList ReplacesIds, + int? RelatedMeterId, + TotalsConflict? RefusedOverride) +{ + /// The first (lowest id) containment parent, or null. + public int? ParentId => ParentIds.Count > 0 ? ParentIds[0] : null; + + public bool IsCounted => Measure is not null; +} + +/// The meters of one measure that share a unit — the only meters that may be added together. +public sealed record MeasureGroup(TotalsMeasure Measure, string Unit, IReadOnlyList MeterIds); + +/// What an energy type's bill is computed from (D-34). +public enum BillingBasis +{ + /// Nothing billable is counted (e.g. a type with only generation meters). + None, + + /// The grid_import meter is billed, because what the supplier charges is what came from the grid. + GridImport, + + /// No grid import is counted, so household use is billed. + Use, +} + +/// +/// A containment child billed at its own meter-scoped price (D-35) — a heat-pump meter in cascade, say. For +/// pricing, its quantity is taken out of so the same energy is not +/// charged twice; quantity totals do not change. carries the same relation in the +/// form costing prices from. +/// +/// +/// A meter is listed once per billed meter its quantity comes out of. More than once only where the grid_import role +/// passed from one meter record to another during its service (A-07): each deduction then applies only within that +/// holder's service period. +/// +/// The separately billed meter. +/// The billed meter whose priced quantity it reduces, or null when none of its ancestors is billed. +/// +/// Set when the billed meter is not itself a containment ancestor, naming the ancestor the relation runs through: the +/// top ancestor the grid import supplies (e.g. the house meter), or the ancestor a virtual +/// sum replaced in the bill (the sum is billed in its place, so the quantity comes out of the sum). Null otherwise. +/// +public sealed record SeparatelyBilledMeter(int MeterId, int? SubtractFromMeterId, int? ThroughMeterId); + +/// How a bill line is priced. +public enum BillLineKind +{ + /// At the normal unit price (tariff precedence meter, type, global). + UnitPrice, + + /// At the meter's own meter-scoped unit price: a separately billed subsection (D-35). + OwnPrice, + + /// A credit: the export at the feed-in price. + FeedIn, +} + +/// +/// A quantity taken out of a bill line before pricing: a separately billed subsection's amount, converted into the +/// line's unit by (; 1 for the same unit). +/// +/// The separately billed meter whose quantity is subtracted. +/// Multiplies the deducted meter's amount into the line meter's unit. +public sealed record BillDeduction(int MeterId, double UnitFactor); + +/// +/// One line of a bill: what costing prices, and how. The priced quantity is the meter's quantity minus every +/// deduction; the price follows . Carrying the deductions on the line is what lets a category that +/// takes a line from the bill price it exactly as the bill does, so a composition of categories reconciles (D-42). +/// +/// The meter the line prices. +/// How the line is priced. +/// The separately billed subsections taken out of it, ascending by meter id. +public sealed record BillLine(int MeterId, BillLineKind Kind, IReadOnlyList Deductions); + +/// The billing set of one energy type (D-34/D-35). +/// Which measure is billed. +/// The meters priced at the normal unit price. +/// The grid_export meters whose export earns the feed-in credit. Generation never does. +/// Containment children priced at their own tariff, with the meter each comes out of. +/// +/// The same bill as priceable lines, one per meter: billed (with deductions), separately billed (with the deductions +/// of a cascade below them) and credited. Costing prices these; the lists above say the same for explanation. +/// +public sealed record TypeBilling( + BillingBasis Basis, + IReadOnlyList BilledMeterIds, + IReadOnlyList FeedInMeterIds, + IReadOnlyList SeparatelyBilled, + IReadOnlyList Lines) +{ + public static readonly TypeBilling Empty = new(BillingBasis.None, [], [], [], []); + + /// Every meter that contributes a line to the bill: billed, separately billed, credited. + public IEnumerable Items => Lines.Select(l => l.MeterId); +} + +/// One energy type's measures and bill. +public sealed record TypeTotals(int EnergyTypeId, IReadOnlyList Measures, TypeBilling Billing) +{ + public static TypeTotals Empty(int energyTypeId) => new(energyTypeId, [], TypeBilling.Empty); + + /// The meters counted in , all units, ascending. + public IReadOnlyList MetersIn(TotalsMeasure measure) => + Measures.Where(g => g.Measure == measure).SelectMany(g => g.MeterIds).Order().ToList(); + + /// The unit groups of ; more than one means the measure has no single total. + public IReadOnlyList GroupsOf(TotalsMeasure measure) => Measures.Where(g => g.Measure == measure).ToList(); +} + +/// What is wrong with the configuration the policy had to work around. +public enum TotalsProblemKind +{ + /// + /// Two meters of one type hold the same role over overlapping service periods; the lowest id keeps it + /// (). + /// + DuplicateRole, + + /// + /// The meter was given a role its mode may not hold (), or any role on a + /// virtual meter (A-07); it is ignored. Only a caller that skips + /// can cause this. + /// + RoleNotApplicable, + + /// A stored was not applied; says why. + OverrideRefused, + + /// The meter is its own containment ancestor, so neither it nor its loop can be a root. + ContainmentCycle, + + /// The total_load meter is linked below another consumption meter, which contradicts "total". + TotalLoadIsContained, + + /// + /// A separately billed subsection's unit does not convert into the unit of the meter it would come out of + /// (), so subtracting it would mix units. It stays inside that meter's + /// bill, at that meter's price, until the units are fixed. + /// + SeparateBillingUnitMismatch, + + /// + /// The meter has a meter-scoped unit price but no bill line uses it: it is neither billed nor a linked subsection + /// of a billed meter (an unlinked meter is only assumed inside the total load, which is not enough to take it out + /// of the grid bill). Link it below the meter that supplies it, or remove the price. + /// + UnusedMeterPrice, +} + +/// A configuration problem, as data: the UI names the meters and localizes the kind. +public sealed record TotalsProblem( + TotalsProblemKind Kind, + int MeterId, + int? OtherMeterId = null, + MeterRole? Role = null, + TotalsConflictReason? Conflict = null); + +/// A place where the configuration cannot rule out an overlap (D-53); an attention item, not an error. +public enum OverlapHintKind +{ + /// The type has a total_load and a grid_import meter, in service at the same time, with no link between them. + GridImportNotLinkedToTotalLoad, + + /// A consumption meter is not linked anywhere and is assumed to be part of the total_load meter. + NotLinkedBelowTotalLoad, +} + +/// A possible-overlap hint: and the total_load meter . +public sealed record OverlapHint(OverlapHintKind Kind, int EnergyTypeId, int MeterId, int OtherMeterId); diff --git a/src/Core/Analysis/Totals/TotalsPolicy.cs b/src/Core/Analysis/Totals/TotalsPolicy.cs new file mode 100644 index 0000000..143c4aa --- /dev/null +++ b/src/Core/Analysis/Totals/TotalsPolicy.cs @@ -0,0 +1,162 @@ +namespace MeterVault.Core.Analysis.Totals; + +/// +/// The shared aggregation policy (brief §6): which meters make up each energy type's totals and bill, and why. +/// +/// +/// +/// Summing every meter of a type counts the same energy several times: the house meter, the grid meter that feeds +/// it, the car charger inside it and a virtual sum of the solar strings all see overlapping flows. So each type +/// reports separate measures (D-22) — household use, grid import, export, generation, runtime — each built from +/// meters that do not overlap, and each split by unit. The algorithm is ordered on purpose: supply meters +/// (grid import/export, generation) are identified first, because a link out of a supply meter says "this feeds +/// that", not "that is part of this"; only links between plain consumption meters make a subsection. +/// +/// +/// The bill (D-34) is a separate question: the supplier charges what came from the grid, so a type with a grid +/// import meter bills that, and otherwise bills household use. On the reference data this is exactly the sheet's +/// Kosten = Netz × €/kWh. Overrides (D-23) let a user count a meter the topology leaves out, but only +/// where that cannot double-count; the same cover then drives quantities and the bill alike. +/// +/// +/// Lifecycle dates (D-24) do not move a meter between measures: a retired meter keeps its place so that its history +/// keeps counting, and the reader contributes a known zero outside its dates. They matter only where meters succeed +/// each other (A-07): a role is unique among meters in service at the same time, so a retired grid meter and its +/// replacement both hold grid_import and both stay billed, each for its own period; and an unlinked consumer is +/// assumed to be inside only the total_load meters that were in service with it. +/// +/// +/// Roles are read as gives them (A-07): case-insensitive, +/// only on modes that may hold them, never on a virtual meter. +/// +/// +public static class TotalsPolicy +{ + /// Classifies every meter and builds each energy type's measures and billing set. + /// All meters of the instance (physical and virtual, active or not). + /// All topology links; links across energy types or to unknown meters are ignored. + /// + /// Whether a meter has an applicable meter-scoped unit price (D-35); none when null. Asked once per meter during + /// this call and never afterwards, so it may close over state that is disposed once the call returns. + /// + public static TotalsClassification Classify( + IEnumerable meters, + IEnumerable links, + Func? hasMeterScopedUnitPrice = null) + { + var graph = TotalsGraph.Build(meters, links, hasMeterScopedUnitPrice); + return new TotalsClassification(graph, TotalsRun.Execute(graph, graph.OrderedIds)); + } + + /// + /// Whether saving on keeps the totals free of overlap + /// (D-23). An is refused when a meter already counted in the same measure is + /// its containment ancestor or subsection, or shares a source with it (a virtual dependency or dependent), when it + /// would sit beside a counted total_load meter, and — on a virtual meter — when its sources lie in another energy + /// type or measure or overlap each other; on a virtual pure sum it replaces its sources instead. + /// and are also refused when they would force + /// another meter's existing out, naming that meter. + /// + /// + /// is always allowed. Removing a meter can never count anything twice, and a + /// user must always be able to exclude a meter. The removal can still let a lower-id stored + /// apply that then pushes a higher-id one out (stored overrides apply in id + /// order); that one is reported as by the next classification, + /// where the user sees and resolves it, rather than blocking an exclusion the user cannot rephrase. + /// + /// is not among . + public static TotalsOverrideCheck Validate( + IEnumerable meters, + IEnumerable links, + int meterId, + TotalsOverride newOverride) + { + var graph = TotalsGraph.Build(meters, links, null); + if (!graph.Meters.ContainsKey(meterId)) + { + throw new ArgumentException($"Meter {meterId} is not among the meters given.", nameof(meterId)); + } + + var before = TotalsRun.Execute(graph, graph.OrderedIds); + // The new override is applied after the saved ones: they are "already counted", and it is the change that + // has to fit around them (D-23), not the other way round. + var after = TotalsRun.Execute(graph, graph.OrderedIds, m => m.Id == meterId ? newOverride : m.Override, meterId); + + if (after.Entries[meterId].RefusedOverride is { } own) + { + return new TotalsOverrideCheck(meterId, newOverride, own); + } + + if (newOverride == TotalsOverride.Never) + { + return new TotalsOverrideCheck(meterId, newOverride, null); + } + + foreach (var id in graph.OrderedIds) + { + if (id != meterId && after.Entries[id].RefusedOverride is not null && before.Entries[id].RefusedOverride is null) + { + return new TotalsOverrideCheck(meterId, newOverride, new TotalsConflict(TotalsConflictReason.DisplacesOverride, id)); + } + } + + return new TotalsOverrideCheck(meterId, newOverride, null); + } + + /// The possible-overlap hints (D-53) of a configuration; the same list as . + public static IReadOnlyList PossibleOverlapHints(IEnumerable meters, IEnumerable links) => + Classify(meters, links).Hints; +} + +/// +/// The result of : every meter's place, every type's measures and bill, and +/// what the configuration got wrong. Also the starting point for category covers (), +/// which rerun the same algorithm on a category's members. +/// +public sealed class TotalsClassification +{ + private readonly HashSet _billItems; + private readonly Dictionary _lines; + + internal TotalsClassification(TotalsGraph graph, TotalsRun run) + { + Graph = graph; + Meters = run.Entries; + Types = run.Types; + Problems = [.. graph.RoleProblems, .. run.Problems]; + Hints = run.Hints; + _lines = run.Types.Values.SelectMany(t => t.Billing.Lines).ToDictionary(l => l.MeterId); + _billItems = [.. _lines.Keys]; + } + + /// Every meter's classification, by id. + public IReadOnlyDictionary Meters { get; } + + /// Every energy type that has meters, by id. + public IReadOnlyDictionary Types { get; } + + /// Configuration problems worked around (duplicate roles, refused overrides, cycles, …): attention items. + public IReadOnlyList Problems { get; } + + /// Possible overlaps the configuration cannot rule out (D-53). + public IReadOnlyList Hints { get; } + + /// Every meter that puts a line on the bill: billed, separately billed or credited for feed-in. + public IReadOnlySet BillItems => _billItems; + + internal TotalsGraph Graph { get; } + + /// The meter was not classified. + public MeterTotalsEntry For(int meterId) => Meters[meterId]; + + /// The type's totals; empty (nothing counted, nothing billed) for a type without meters. + public TypeTotals ForType(int energyTypeId) => Types.TryGetValue(energyTypeId, out var totals) ? totals : TypeTotals.Empty(energyTypeId); + + public bool IsBilled(int meterId) => _billItems.Contains(meterId); + + /// + /// The bill line of a meter — how the bill prices it and what it deducts — or null when the meter puts no line on + /// the bill. Costing prices Uncategorized () with these. + /// + public BillLine? LineOf(int meterId) => _lines.GetValueOrDefault(meterId); +} diff --git a/src/Core/Analysis/Totals/TotalsRun.cs b/src/Core/Analysis/Totals/TotalsRun.cs new file mode 100644 index 0000000..e56d845 --- /dev/null +++ b/src/Core/Analysis/Totals/TotalsRun.cs @@ -0,0 +1,655 @@ +using MeterVault.Core.Analysis.Quantities; + +namespace MeterVault.Core.Analysis.Totals; + +/// +/// One pass of the totals algorithm (D-22 step 3, D-23, D-34, D-35) over a set of member meters: the whole +/// instance for the bill, or a category's members for its cover (D-42). Everything the configuration fixes — +/// natures, roles, supply, containment — comes from the shared ; only what depends on +/// who is present is decided here. +/// +internal sealed class TotalsRun +{ + private readonly TotalsGraph _graph; + private readonly HashSet _members; + private readonly IReadOnlyList _orderedMembers; + private readonly Func _overrideOf; + private readonly int? _lastAlways; + private readonly Dictionary> _runParents = []; + private readonly Dictionary> _runAssumedParents = []; + private readonly Dictionary> _ancestorCache = []; + private readonly Dictionary _entries = []; + private readonly Dictionary _types = []; + private readonly List _problems = []; + private readonly List _hints = []; + + private TotalsRun(TotalsGraph graph, IEnumerable members, Func overrideOf, int? lastAlways) + { + _graph = graph; + _members = [.. members.Where(graph.Meters.ContainsKey)]; + _orderedMembers = [.. _members.Order()]; + _overrideOf = overrideOf; + _lastAlways = lastAlways; + } + + public IReadOnlyDictionary Entries => _entries; + + public IReadOnlyDictionary Types => _types; + + public IReadOnlyList Problems => _problems; + + public IReadOnlyList Hints => _hints; + + /// The instance's configuration facts. + /// The meters taking part (all of them for the bill). + /// The override to use per meter; the stored one when null. + /// A meter whose is applied after every other one — + /// the one being validated, so it is judged against what is already counted rather than competing by id. + public static TotalsRun Execute( + TotalsGraph graph, + IEnumerable members, + Func? overrideOf = null, + int? lastAlways = null) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(members); + + var run = new TotalsRun(graph, members, overrideOf ?? (m => m.Override), lastAlways); + var types = run._orderedMembers + .GroupBy(id => graph.Meters[id].EnergyTypeId) + .OrderBy(g => g.Key) + .Select(g => (Type: g.Key, Ids: (IReadOnlyList)g.ToList())) + .ToList(); + + // Structure first for every type, overrides and bills second: an overlap check may look at a meter of + // another type (a virtual source), and must see that meter's structure complete. + foreach (var id in run._orderedMembers) + { + run._runParents[id] = run.NearestMemberAncestors(id); + } + + var counted = types.ToDictionary(t => t.Type, t => run.ClassifyNaturally(t.Type, t.Ids)); + foreach (var (type, ids) in types) + { + run.ApplyAlways(ids, counted[type]); + run._types[type] = new TypeTotals(type, run.Groups(counted[type]), run.Billing(ids, counted[type])); + } + + return run; + } + + /// + /// The topology alone (D-22), then . Overrides never change the structure, + /// only which of the structure's meters are counted. Returns the counted meters per measure. + /// + private Dictionary> ClassifyNaturally(int energyTypeId, IReadOnlyList ids) + { + var totalLoads = MemberHolders(energyTypeId, MeterRole.TotalLoad); + var aboveTotalLoad = new Dictionary(); + foreach (var load in totalLoads) + { + foreach (var above in TotalsGraph.Closure(load, RunParentsOf).Order()) + { + aboveTotalLoad.TryAdd(above, load); + } + + if (_runParents[load].Count > 0) + { + _problems.Add(new TotalsProblem(TotalsProblemKind.TotalLoadIsContained, load, _runParents[load][0])); + } + } + + foreach (var id in ids.Where(id => TotalsGraph.Closure(id, _graph.ParentsOf).Contains(id))) + { + _problems.Add(new TotalsProblem(TotalsProblemKind.ContainmentCycle, id)); + } + + var counted = Enum.GetValues().ToDictionary(m => m, _ => new SortedSet()); + foreach (var id in ids) + { + var entry = Natural(id, totalLoads, aboveTotalLoad); + if (entry.Reason == MeterTotalsReason.AssumedInsideTotalLoad && entry.RelatedMeterId is { } holder) + { + _runAssumedParents[id] = entry.ParentIds; + _hints.Add(new OverlapHint(OverlapHintKind.NotLinkedBelowTotalLoad, energyTypeId, id, holder)); + } + + if (_overrideOf(_graph.Meters[id]) == TotalsOverride.Never) + { + _entries[id] = entry with + { + Class = MeterTotalsClass.ExcludedByOverride, + Reason = MeterTotalsReason.OverrideNever, + Measure = null, + RelatedMeterId = null, + }; + continue; + } + + _entries[id] = entry; + if (entry.Measure is { } measure) + { + counted[measure].Add(id); + } + } + + AddGridHints(energyTypeId, totalLoads); + return counted; + } + + /// + /// Where the topology alone puts a meter. Order matters: virtual first (a calculation is never a device), + /// then roles, then generation and runtime, and only then containment and roots — so a supply meter is never + /// mistaken for a subsection or a root of household use. + /// + private MeterTotalsEntry Natural(int id, IReadOnlyList totalLoads, Dictionary aboveTotalLoad) + { + var nature = _graph.NatureOf(id); + var parents = _runParents[id]; + + if (_graph.DuplicateOf(id) is { } holder) + { + return Entry(id, MeterTotalsClass.NotCounted, MeterTotalsReason.DuplicateRole, null, related: holder); + } + + if (_graph.DeclaredRoleOf(id) is { } role) + { + return RoleEntry(id, role); + } + + if (nature == MeterNature.Calculated) + { + return Entry(id, MeterTotalsClass.AnalysisOnly, MeterTotalsReason.VirtualView, null); + } + + if (nature == MeterNature.Generation) + { + return parents.Count > 0 + ? Entry(id, MeterTotalsClass.Breakdown, MeterTotalsReason.ContainedByLink, null) + : Entry(id, MeterTotalsClass.Generation, MeterTotalsReason.GenerationMeter, TotalsMeasure.Generation); + } + + if (nature == MeterNature.Runtime) + { + return Entry(id, MeterTotalsClass.Runtime, MeterTotalsReason.RuntimeMeter, TotalsMeasure.Runtime); + } + + if (parents.Count > 0) + { + return Entry(id, MeterTotalsClass.Breakdown, MeterTotalsReason.ContainedByLink, null); + } + + if (aboveTotalLoad.TryGetValue(id, out var contained)) + { + return Entry(id, MeterTotalsClass.NotCounted, MeterTotalsReason.ContainsTotalLoad, null, related: contained); + } + + var around = _graph.TotalLoadsAround(id, totalLoads, RunParentsOf); + return around.Count > 0 + ? Entry(id, MeterTotalsClass.Breakdown, MeterTotalsReason.AssumedInsideTotalLoad, null, around, around[0]) + : Entry(id, MeterTotalsClass.Use, MeterTotalsReason.ConsumptionRoot, TotalsMeasure.Use); + } + + private MeterTotalsEntry RoleEntry(int id, MeterRole role) => role switch + { + MeterRole.TotalLoad => Entry(id, MeterTotalsClass.Use, MeterTotalsReason.TotalLoadRole, TotalsMeasure.Use), + MeterRole.GridImport => Entry(id, MeterTotalsClass.GridImport, MeterTotalsReason.GridImportRole, TotalsMeasure.GridImport), + _ => Entry(id, MeterTotalsClass.Export, MeterTotalsReason.GridExportRole, TotalsMeasure.Export), + }; + + private MeterTotalsEntry Entry( + int id, + MeterTotalsClass cls, + MeterTotalsReason reason, + TotalsMeasure? measure, + IReadOnlyList? parents = null, + int? related = null) => + new(id, _graph.Meters[id].EnergyTypeId, cls, reason, cls, measure, parents ?? _runParents[id], [], related, null); + + /// + /// D-23: each , lowest id first (a meter under validation last), joins the + /// measure its kind belongs to — unless it would overlap something already counted there. A virtual pure sum + /// takes the place of its sources; nothing else is ever replaced, so an explicit selection can swap coverage but + /// never add it twice. When two stored overrides collide (only possible in data that bypassed validation), the + /// lower id wins and the other is reported, so the result never depends on load order. + /// + private void ApplyAlways(IReadOnlyList ids, Dictionary> counted) + { + var forced = new HashSet(); + var always = ids + .Where(id => _overrideOf(_graph.Meters[id]) == TotalsOverride.Always) + .OrderBy(id => id == _lastAlways) + .ThenBy(id => id); + foreach (var id in always) + { + var entry = _entries[id]; + if (entry.Measure is not null) + { + forced.Add(id); + continue; + } + + var conflict = TryInclude(id, counted, forced, out var measure, out var replaced); + if (conflict is not null) + { + _entries[id] = entry with { RefusedOverride = conflict }; + _problems.Add(new TotalsProblem(TotalsProblemKind.OverrideRefused, id, conflict.OtherMeterId, Conflict: conflict.Reason)); + continue; + } + + foreach (var source in replaced) + { + counted[measure].Remove(source); + _entries[source] = _entries[source] with + { + Class = MeterTotalsClass.ExcludedByOverride, + Reason = MeterTotalsReason.CoveredByOverride, + Measure = null, + RelatedMeterId = id, + }; + } + + counted[measure].Add(id); + forced.Add(id); + _entries[id] = entry with + { + Class = MeterTotalsClass.IncludedByOverride, + Reason = MeterTotalsReason.OverrideAlways, + Measure = measure, + ReplacesIds = replaced, + RelatedMeterId = null, + }; + } + } + + /// + /// Whether an can join its measure without counting energy twice. Overlap is + /// not only a shared meter or containment inside one measure: a sum over the grid import, an export or a + /// generator adds energy another measure holds (the grid's energy is already inside the house meter), a sum over + /// another type's meter counts it in two types, a sum listing a meter twice counts it twice, and anything added + /// beside a counted total_load meter is by definition already inside it. + /// + private TotalsConflict? TryInclude( + int id, + Dictionary> counted, + HashSet forced, + out TotalsMeasure measure, + out IReadOnlyList replaced) + { + var meter = _graph.Meters[id]; + var entry = _entries[id]; + measure = default; + replaced = []; + + if (entry.Reason == MeterTotalsReason.DuplicateRole) + { + return new TotalsConflict(TotalsConflictReason.RoleConflict, entry.RelatedMeterId); + } + + var calculated = _graph.NatureOf(id) == MeterNature.Calculated; + if (_graph.NaturalMeasureOf(id) is not { } target) + { + return new TotalsConflict(TotalsConflictReason.NotAdditive, null); + } + + if (calculated && SourceConflict(meter, target) is { } sourceConflict) + { + return sourceConflict; + } + + measure = target; + var footprint = Footprint(id); + var sources = calculated ? meter.VirtualSources.ToHashSet() : []; + var replaceable = counted[measure].Where(c => sources.Contains(c) && !forced.Contains(c)).ToList(); + + if (measure == TotalsMeasure.Use) + { + foreach (var load in MemberHolders(meter.EnergyTypeId, MeterRole.TotalLoad)) + { + if (counted[measure].Contains(load) && !replaceable.Contains(load) && InServiceWith(footprint, load)) + { + return new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, load); + } + } + } + + foreach (var other in counted[measure].Where(c => !replaceable.Contains(c))) + { + if (Overlaps(footprint, Footprint(other))) + { + return new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, other); + } + } + + replaced = replaceable; + return null; + } + + /// + /// What makes a virtual meter's sources unfit to stand in for anything: a formula that is not a pure sum, a + /// source in another energy type or another measure (or unknown), or sources that overlap each other. + /// + private TotalsConflict? SourceConflict(TotalsMeter meter, TotalsMeasure target) + { + if (!meter.VirtualIsPureSum || meter.VirtualSources.Count == 0) + { + return new TotalsConflict(TotalsConflictReason.NotAPureSum, null); + } + + var distinct = meter.VirtualSources.Distinct().Order().ToList(); + foreach (var source in distinct) + { + if (_graph.Meters.TryGetValue(source, out var other) && other.EnergyTypeId != meter.EnergyTypeId) + { + return new TotalsConflict(TotalsConflictReason.SourceInOtherEnergyType, source); + } + } + + foreach (var source in distinct) + { + if (!_graph.Meters.ContainsKey(source) || _graph.NaturalMeasureOf(source) != target) + { + return new TotalsConflict(TotalsConflictReason.SourceInOtherMeasure, source); + } + } + + return SourcesOverlap(meter.VirtualSources) is { } overlapping + ? new TotalsConflict(TotalsConflictReason.SourcesOverlap, overlapping) + : null; + } + + /// The physical flows a counted meter stands for: itself, plus a virtual meter's expanded sources. + private HashSet Footprint(int id) + { + var footprint = new HashSet { id }; + if (_graph.NatureOf(id) == MeterNature.Calculated) + { + footprint.UnionWith(_graph.Meters[id].VirtualSources); + } + + return footprint; + } + + /// + /// Whether any physical meter of a footprint was in service together with . A virtual + /// meter has no service of its own: it measures whenever its sources do. + /// + private bool InServiceWith(HashSet footprint, int meterId) => + footprint.Any(m => _graph.Meters.TryGetValue(m, out var meter) + && !TotalsGraph.IsCalculated(meter) + && _graph.InServiceTogether(m, meterId)); + + /// Two footprints overlap when they share a meter or one holds an ancestor of the other's. + private bool Overlaps(HashSet left, HashSet right) => + left.Any(l => right.Any(r => l == r || Ancestors(r).Contains(l) || Ancestors(l).Contains(r))); + + /// + /// The first source that the sum counts twice: one listed more than once (the expansion keeps multiplicity), else + /// the lowest id that is a containment ancestor of another source. Null when the sources are disjoint. + /// + private int? SourcesOverlap(IReadOnlyList sources) + { + var repeated = sources.GroupBy(s => s).Where(g => g.Count() > 1).Select(g => g.Key).Order().ToList(); + if (repeated.Count > 0) + { + return repeated[0]; + } + + var distinct = sources.Order().ToList(); + foreach (var candidate in distinct) + { + if (distinct.Any(other => other != candidate && Ancestors(other).Contains(candidate))) + { + return candidate; + } + } + + return null; + } + + /// + /// Containment parents for overlap and billing questions: the configured links, plus the total_load meters a + /// meter is taken to be inside — in this run, or in the full topology when a category left them out. + /// + private IEnumerable ParentsFor(int id) => + _graph.ParentsOf(id) + .Concat(_runAssumedParents.GetValueOrDefault(id) ?? []) + .Concat(_graph.AssumedParentsOf(id)) + .Distinct(); + + private HashSet Ancestors(int id) + { + if (!_ancestorCache.TryGetValue(id, out var ancestors)) + { + ancestors = TotalsGraph.Closure(id, ParentsFor); + _ancestorCache[id] = ancestors; + } + + return ancestors; + } + + private IReadOnlyList RunParentsOf(int id) => _runParents.GetValueOrDefault(id) ?? []; + + /// + /// The containment parents that are members of this run: walk up the full topology and stop at the first + /// member on each branch. A category holding the house and its heat pump but not the basement meter between + /// them still sees the heat pump as a subsection of the house. + /// + private IReadOnlyList NearestMemberAncestors(int id) + { + var found = new SortedSet(); + var seen = new HashSet { id }; + var queue = new Queue(_graph.ParentsOf(id)); + while (queue.Count > 0) + { + var parent = queue.Dequeue(); + if (!seen.Add(parent)) + { + continue; + } + + if (_members.Contains(parent)) + { + found.Add(parent); + continue; + } + + foreach (var next in _graph.ParentsOf(parent)) + { + queue.Enqueue(next); + } + } + + return [.. found]; + } + + private IReadOnlyList MemberHolders(int energyTypeId, MeterRole role) => + [.. _graph.HoldersOf(energyTypeId, role).Where(_members.Contains)]; + + /// Each measure split by unit (D-22 step 4, never add across units), units compared as . + private List Groups(Dictionary> counted) => + [.. Enum.GetValues() + .SelectMany(measure => counted[measure] + .GroupBy(id => Units.Normalize(_graph.Meters[id].Unit), StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.Ordinal) + .Select(g => new MeasureGroup(measure, g.Key, [.. g.Order()])))]; + + /// + /// D-34/D-35: the grid import is billed when the type counts one — the supplier charges what came from the + /// grid, not what the house used — otherwise household use. Generation is never billed; the feed-in credit is + /// earned by the export meters only. A linked containment child with its own meter-scoped price is billed at that + /// price and taken out of the nearest billed meter above it (or the grid import supplying it), provided its unit + /// converts into that meter's. + /// + private TypeBilling Billing(IReadOnlyList ids, Dictionary> counted) + { + var basis = counted[TotalsMeasure.GridImport].Count > 0 ? BillingBasis.GridImport + : counted[TotalsMeasure.Use].Count > 0 ? BillingBasis.Use + : BillingBasis.None; + List billed = basis switch + { + BillingBasis.GridImport => [.. counted[TotalsMeasure.GridImport]], + BillingBasis.Use => [.. counted[TotalsMeasure.Use]], + _ => [], + }; + IReadOnlyList gridImports = basis == BillingBasis.GridImport ? billed : []; + + var candidates = ids.Where(id => IsSeparatelyBilled(id, billed, gridImports)).ToList(); + var mismatched = new HashSet(); + List separately; + while (true) + { + var priced = billed.Concat(candidates).ToHashSet(); + separately = [.. candidates.SelectMany(id => SeparateLines(id, priced, gridImports))]; + var failed = separately + .Where(s => s.SubtractFromMeterId is { } target && UnitFactor(s.MeterId, target) is null) + .ToList(); + if (failed.Count == 0) + { + break; + } + + // Drop the topmost failures only: a meter below a dropped one moves up to the next billed meter, whose + // unit may well fit — so it is judged again against that one rather than against the meter that left. + var failedIds = failed.Select(f => f.MeterId).ToHashSet(); + var topmost = failed.Where(f => !failedIds.Contains(f.SubtractFromMeterId!.Value)).ToList(); + foreach (var line in topmost.Count > 0 ? topmost : failed) + { + if (mismatched.Add(line.MeterId)) + { + _problems.Add(new TotalsProblem(TotalsProblemKind.SeparateBillingUnitMismatch, line.MeterId, line.SubtractFromMeterId)); + candidates.Remove(line.MeterId); + } + } + } + + var lined = billed.Concat(candidates).ToHashSet(); + foreach (var id in ids.Where(id => _graph.HasMeterScopedUnitPrice(id) && !lined.Contains(id) && !mismatched.Contains(id))) + { + _problems.Add(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, id)); + } + + var feedIn = counted[TotalsMeasure.Export].ToList(); + List lines = + [ + .. billed.Select(id => new BillLine(id, BillLineKind.UnitPrice, DeductionsFrom(id, separately))), + .. candidates.Order().Select(id => new BillLine(id, BillLineKind.OwnPrice, DeductionsFrom(id, separately))), + .. feedIn.Select(id => new BillLine(id, BillLineKind.FeedIn, [])), + ]; + + return new TypeBilling(basis, billed, feedIn, separately, lines); + } + + private List DeductionsFrom(int lineMeterId, List separately) => + [.. separately + .Where(s => s.SubtractFromMeterId == lineMeterId) + .Select(s => s.MeterId) + .Distinct() + .Order() + .Select(id => new BillDeduction(id, UnitFactor(id, lineMeterId) ?? 1d))]; + + /// The factor turning 's amounts into 's unit, or null. + private double? UnitFactor(int from, int to) + { + var fromUnit = _graph.Meters[from].Unit; + var toUnit = _graph.Meters[to].Unit; + return Units.AreSame(fromUnit, toUnit) ? 1d : Units.ConversionFactor(fromUnit, toUnit); + } + + /// + /// D-35 applies to a consumption meter linked inside another (in the whole topology — a category may have left + /// the parent out) that is not already billed and has its own price. Being assumed inside the total load is not + /// enough: that is a guess about an unlinked meter, and a guess must not take energy out of the grid bill. + /// + /// + /// A consumer linked directly below a billed grid_import meter qualifies as well (A-19, the German Kaskade: a main + /// meter and a heat-pump or wallbox meter behind it, no house meter). D-22 makes that link a supply edge, so the + /// consumer is a use root rather than a containment child, but the link states that the grid meter measured it — + /// no guess. Its quantity comes out of the grid meters linking to it (). + /// + private bool IsSeparatelyBilled(int id, List billed, IReadOnlyList gridImports) => + _graph.IsContainableConsumer(id) + && _graph.DeclaredRoleOf(id) is null + && _entries[id].Class is not (MeterTotalsClass.ExcludedByOverride or MeterTotalsClass.NotCounted) + && !billed.Contains(id) + && (_graph.ParentsOf(id).Count > 0 || SupplyingGrids(id, gridImports).Count > 0) + && _graph.HasMeterScopedUnitPrice(id); + + /// The billed grid_import meters that link directly to and are in service with it. + private List SupplyingGrids(int id, IReadOnlyList gridImports) => + [.. gridImports.Where(grid => grid != id && _graph.LinkTargetsOf(grid).Contains(id) && _graph.InServiceTogether(grid, id))]; + + /// + /// Breadth-first up the containment, nearest level first and lowest id within a level, so a cascade of + /// separately billed meters subtracts each from the one directly above it. An ancestor a virtual + /// sum replaced is billed through that sum, so the quantity comes out of the + /// sum. When nothing above is billed and the type bills its grid import, the grid import supplied this meter too, + /// so the quantity comes out of each grid import meter in service with it (one, unless the role passed on). + /// + private IEnumerable SeparateLines(int id, HashSet priced, IReadOnlyList gridImports) + { + var seen = new HashSet { id }; + var level = new SortedSet(ParentsFor(id).Where(p => p != id)); + var tops = new SortedSet(); + while (level.Count > 0) + { + foreach (var ancestor in level) + { + var billedAs = BilledAs(ancestor); + if (priced.Contains(billedAs)) + { + return [new SeparatelyBilledMeter(id, billedAs, billedAs == ancestor ? null : ancestor)]; + } + } + + seen.UnionWith(level); + var next = new SortedSet(); + foreach (var ancestor in level) + { + var parents = ParentsFor(ancestor).ToList(); + if (parents.Count == 0) + { + tops.Add(ancestor); + } + + next.UnionWith(parents.Where(p => !seen.Contains(p))); + } + + level = next; + } + + // Linked directly below a grid meter (no containment parent): it comes out of the grid meters that link to it. + var linking = ParentsFor(id).Any() ? [] : SupplyingGrids(id, gridImports); + var grids = linking.Count > 0 ? linking : gridImports.Where(grid => _graph.InServiceTogether(grid, id)).ToList(); + int? through = tops.Count > 0 ? tops.Min : null; + return grids.Count > 0 + ? grids.Select(grid => new SeparatelyBilledMeter(id, grid, through)) + : [new SeparatelyBilledMeter(id, null, null)]; + } + + /// The meter a bill line for sits on: the covering Always sum when one replaced it. + private int BilledAs(int id) => + _entries.TryGetValue(id, out var entry) && entry is { Reason: MeterTotalsReason.CoveredByOverride, RelatedMeterId: { } sum } + ? sum + : id; + + /// + /// D-53: a total_load and a grid_import meter in service together, with no link path either way, may overlap + /// unnoticed. + /// + private void AddGridHints(int energyTypeId, IReadOnlyList totalLoads) + { + foreach (var grid in MemberHolders(energyTypeId, MeterRole.GridImport)) + { + foreach (var load in totalLoads.Where(load => _graph.InServiceTogether(grid, load))) + { + if (!Reaches(grid, load) && !Reaches(load, grid)) + { + _hints.Add(new OverlapHint(OverlapHintKind.GridImportNotLinkedToTotalLoad, energyTypeId, grid, load)); + } + } + } + } + + private bool Reaches(int from, int to) => TotalsGraph.Closure(from, _graph.LinkTargetsOf).Contains(to); +} diff --git a/src/Core/Analysis/Virtual/DependencyGraph.cs b/src/Core/Analysis/Virtual/DependencyGraph.cs new file mode 100644 index 0000000..1671a05 --- /dev/null +++ b/src/Core/Analysis/Virtual/DependencyGraph.cs @@ -0,0 +1,430 @@ +using System.Globalization; + +namespace MeterVault.Core.Analysis.Virtual; + +/// +/// The calculation dependencies between virtual meters (D-26, D-27): which meters each virtual meter's formula +/// refers to, in what order they can be evaluated, where the dependencies loop, and which physical meters a set +/// of virtual meters finally reads. +/// +/// +/// +/// A virtual meter may refer to another virtual meter, so evaluation has to run dependencies first, and a loop +/// (A = B + 1, B = A - 1) has no value at all. Nothing in storage prevents such a loop — the formula +/// lives in JSON — so it is found here and reported with its path, and every meter that depends on it is +/// withheld from rather than evaluated to a wrong number. +/// +/// +/// Every id that is not a key of the graph is a physical leaf, including ids no meter has; whether a leaf exists +/// is the validator's question, not the graph's. The reader expands a request to its +/// first, so it loads each physical series once, however many virtual meters share it (D-15). +/// +/// All walks are iterative: a chain of a thousand virtual meters is a long list, not a deep call stack. +/// +public sealed class DependencyGraph +{ + /// + /// Separates the meter ids of a dependency path written as text (logs, CSV export): 12>7>12. In an + /// analysis value the path travels as ids in , never as text to parse back. + /// + public const char PathSeparator = '>'; + + private readonly Dictionary _dependencies; + private readonly Dictionary _componentOf = []; + private readonly List _components = []; + private readonly HashSet _blocked = []; + + private DependencyGraph(Dictionary dependencies) + { + _dependencies = dependencies; + FindCycles(); + EvaluationOrder = PostOrder(_dependencies.Keys.Order()); + Cycles = [.. _components.Select(c => (IReadOnlyList)CycleThrough(c.Min(), c))]; + } + + /// The virtual meters (the graph's keys), ascending. + public IReadOnlyList VirtualMeters => [.. _dependencies.Keys.Order()]; + + /// Every virtual meter that can be evaluated, each after all of its dependencies. + public IReadOnlyList EvaluationOrder { get; } + + /// One closed path per dependency loop, starting and ending at its lowest id: [5, 7, 5]. + public IReadOnlyList> Cycles { get; } + + /// + /// Builds the graph from each virtual meter's referenced ids. Keys are the virtual meters; a virtual meter without + /// a calculation (a legacy meter) belongs in it with no dependencies, so it is not mistaken for a physical leaf. + /// + public static DependencyGraph Build(IReadOnlyDictionary> dependencies) + { + ArgumentNullException.ThrowIfNull(dependencies); + + return new DependencyGraph(dependencies.ToDictionary(p => p.Key, p => p.Value.Distinct().Order().ToArray())); + } + + /// The graph of every virtual meter in , by the ids its definition references. + public static DependencyGraph FromCatalog(MeterCatalog catalog) => FromCatalog(catalog, null, null); + + /// Formats a dependency path as text, e.g. 12>7. + public static string FormatPath(IEnumerable path) => + string.Join(PathSeparator, path.Select(id => id.ToString(CultureInfo.InvariantCulture))); + + public bool IsVirtual(int meterId) => _dependencies.ContainsKey(meterId); + + /// The ids a virtual meter's formula refers to, ascending; empty for a physical leaf. + public IReadOnlyList DirectDependencies(int meterId) => _dependencies.TryGetValue(meterId, out var deps) ? deps : []; + + /// False when the meter is on a dependency loop or depends on one; physical leaves are always evaluable. + public bool IsEvaluable(int meterId) => !_blocked.Contains(meterId); + + /// + /// The loop that keeps from being evaluated, as a path from the meter into and once + /// around the loop — [1, 2, 3, 2] for 1 → 2 → 3 → 2 — or null when the meter is evaluable. + /// + public IReadOnlyList? CycleFor(int meterId) + { + if (!_blocked.Contains(meterId)) + { + return null; + } + + if (_componentOf.TryGetValue(meterId, out var own)) + { + return CycleThrough(meterId, _components[own]); + } + + // Blocked without being on a loop: walk to the nearest meter that is, then once around its loop. + var parents = new Dictionary { [meterId] = meterId }; + var queue = new Queue([meterId]); + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var next in DirectDependencies(current)) + { + if (!_blocked.Contains(next) || !parents.TryAdd(next, current)) + { + continue; + } + + if (_componentOf.TryGetValue(next, out var component)) + { + var lead = Unwind(parents, next); + return [.. lead, .. CycleThrough(next, _components[component]).Skip(1)]; + } + + queue.Enqueue(next); + } + } + + return null; + } + + /// The evaluable virtual meters reachable from , each after its dependencies. + public IReadOnlyList EvaluationOrderFor(IEnumerable meterIds) + { + ArgumentNullException.ThrowIfNull(meterIds); + + return PostOrder(meterIds.Distinct().Order()); + } + + /// The physical meters a meter finally reads: itself when physical, else its formula's leaves, transitively. + public IReadOnlyList PhysicalLeaves(int meterId) => PhysicalLeaves([meterId]); + + /// The distinct physical meters, ascending, that finally read. + public IReadOnlyList PhysicalLeaves(IEnumerable meterIds) + { + ArgumentNullException.ThrowIfNull(meterIds); + + var leaves = new SortedSet(); + var seen = new HashSet(); + var pending = new Stack(meterIds); + while (pending.Count > 0) + { + var current = pending.Pop(); + if (!seen.Add(current)) + { + continue; + } + + if (!_dependencies.TryGetValue(current, out var deps)) + { + leaves.Add(current); + continue; + } + + foreach (var dep in deps) + { + pending.Push(dep); + } + } + + return [.. leaves]; + } + + /// The shortest dependency path [from, …, to], or null when does not depend on . + public IReadOnlyList? PathTo(int from, int to) + { + if (from == to) + { + return [from]; + } + + var parents = new Dictionary { [from] = from }; + var queue = new Queue([from]); + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var next in DirectDependencies(current)) + { + if (!parents.TryAdd(next, current)) + { + continue; + } + + if (next == to) + { + return Unwind(parents, next); + } + + queue.Enqueue(next); + } + } + + return null; + } + + /// The virtual meters that depend on , directly or through others, ascending (D-33). + public IReadOnlyList Dependents(int meterId) + { + var reverse = ReverseEdges(); + var found = new SortedSet(); + var pending = new Stack([meterId]); + while (pending.Count > 0) + { + foreach (var dependent in reverse.GetValueOrDefault(pending.Pop()) ?? []) + { + if (dependent != meterId && found.Add(dependent)) + { + pending.Push(dependent); + } + } + } + + return [.. found]; + } + + /// + /// The graph of the catalog's virtual meters, with 's dependencies replaced by + /// — the definition being validated, before it is saved. + /// + internal static DependencyGraph FromCatalog(MeterCatalog catalog, int? candidateId, IReadOnlyList? candidateDependencies) + { + ArgumentNullException.ThrowIfNull(catalog); + + var dependencies = new Dictionary>(); + foreach (var meter in catalog.Meters.Where(m => m.IsVirtual)) + { + dependencies[meter.MeterId] = meter.Definition?.ReferencedMeterIds ?? []; + } + + if (candidateId is { } id) + { + dependencies[id] = candidateDependencies ?? []; + } + + return Build(dependencies); + } + + private static List Unwind(Dictionary parents, int last) + { + var path = new List { last }; + while (parents[path[^1]] != path[^1]) + { + path.Add(parents[path[^1]]); + } + + path.Reverse(); + return path; + } + + private Dictionary> ReverseEdges() + { + var reverse = new Dictionary>(); + foreach (var (meter, deps) in _dependencies) + { + foreach (var dep in deps) + { + if (!reverse.TryGetValue(dep, out var list)) + { + reverse[dep] = list = []; + } + + list.Add(meter); + } + } + + return reverse; + } + + /// + /// Tarjan's strongly connected components, iteratively. A component of two or more meters, or one meter that + /// refers to itself, is a loop; its meters and everything that depends on them are blocked. + /// + private void FindCycles() + { + var index = 0; + var indices = new Dictionary(); + var lowLinks = new Dictionary(); + var onStack = new HashSet(); + var stack = new Stack(); + var virtualDeps = _dependencies.ToDictionary(p => p.Key, p => p.Value.Where(_dependencies.ContainsKey).ToArray()); + + foreach (var root in _dependencies.Keys.Order()) + { + if (indices.ContainsKey(root)) + { + continue; + } + + var calls = new Stack<(int Node, int Next)>(); + Visit(root); + while (calls.Count > 0) + { + var (node, next) = calls.Pop(); + var deps = virtualDeps[node]; + if (next < deps.Length) + { + calls.Push((node, next + 1)); + var dep = deps[next]; + if (!indices.ContainsKey(dep)) + { + Visit(dep); + } + else if (onStack.Contains(dep)) + { + lowLinks[node] = Math.Min(lowLinks[node], indices[dep]); + } + + continue; + } + + if (lowLinks[node] == indices[node]) + { + var component = new List(); + int member; + do + { + member = stack.Pop(); + onStack.Remove(member); + component.Add(member); + } + while (member != node); + + if (component.Count > 1 || virtualDeps[node].Contains(node)) + { + foreach (var m in component) + { + _componentOf[m] = _components.Count; + } + + _components.Add([.. component.Order()]); + } + } + + if (calls.Count > 0) + { + var parent = calls.Peek().Node; + lowLinks[parent] = Math.Min(lowLinks[parent], lowLinks[node]); + } + } + + void Visit(int node) + { + indices[node] = lowLinks[node] = index++; + stack.Push(node); + onStack.Add(node); + calls.Push((node, 0)); + } + } + + var reverse = ReverseEdges(); + var pending = new Stack(_componentOf.Keys); + while (pending.Count > 0) + { + var current = pending.Pop(); + if (!_blocked.Add(current)) + { + continue; + } + + foreach (var dependent in reverse.GetValueOrDefault(current) ?? []) + { + pending.Push(dependent); + } + } + } + + /// The shortest loop from back to itself inside its component: [start, …, start]. + private List CycleThrough(int start, int[] component) + { + var members = component.ToHashSet(); + var parents = new Dictionary { [start] = start }; + var queue = new Queue([start]); + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var next in DirectDependencies(current).Where(members.Contains)) + { + if (next == start) + { + return [.. Unwind(parents, current), start]; + } + + if (parents.TryAdd(next, current)) + { + queue.Enqueue(next); + } + } + } + + return [start, start]; + } + + /// Dependencies-first order of the evaluable virtual meters reachable from . + private List PostOrder(IEnumerable roots) + { + var order = new List(); + var done = new HashSet(); + foreach (var root in roots) + { + if (!IsVirtual(root) || _blocked.Contains(root) || done.Contains(root)) + { + continue; + } + + var calls = new Stack<(int Node, int Next)>(); + calls.Push((root, 0)); + done.Add(root); + while (calls.Count > 0) + { + var (node, next) = calls.Pop(); + var deps = _dependencies[node]; + if (next < deps.Length) + { + calls.Push((node, next + 1)); + var dep = deps[next]; + if (IsVirtual(dep) && done.Add(dep)) + { + calls.Push((dep, 0)); + } + + continue; + } + + order.Add(node); + } + } + + return order; + } +} diff --git a/src/Core/Analysis/Virtual/Formula.cs b/src/Core/Analysis/Virtual/Formula.cs new file mode 100644 index 0000000..9bb45be --- /dev/null +++ b/src/Core/Analysis/Virtual/Formula.cs @@ -0,0 +1,438 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; + +namespace MeterVault.Core.Analysis.Virtual; + +/// +/// A parsed, validated-by-construction virtual-meter formula (D-26): an immutable tree over numbers, meter +/// references m<id>, unary minus and + - * /. Obtain one from . +/// +/// +/// +/// Beyond evaluating, a formula knows its own shape, because the analysis has to. A linear +/// formula — a weighted sum of meters with no constant term, such as m1 - m2 or 0.5 * m3 — gives the +/// same answer whether it is applied per day and summed or applied once to the month's totals, so its buckets add +/// up to its period total and it can be priced as a quantity. Anything else (m1 / m2, m1 + 5) is +/// non-additive: the sum of monthly ratios is not the ratio of the year, so the evaluator applies it to totals and +/// flags the series (brief §5.3). A pure sum additionally lets costs be the sum of the +/// sources' own costs (D-39). +/// +/// +/// Internally the formula is postfix, and every walk is a loop with an explicit stack. The parser caps +/// parenthesis depth, but a long chain like m1 + m2 + … + m600 is a tree hundreds of levels deep without a +/// single parenthesis — recursion over it would be the stack overflow the depth limit exists to prevent. +/// +/// +/// Equality is structural: m1+m2, (m1) + m2 and m1 + m2 are equal, m2 + m1 is not. +/// keeps what the user wrote; renders a canonical form with minimal +/// parentheses that parses back to an equal formula. +/// +/// +public sealed class Formula : IEquatable +{ + private readonly FormulaInstruction[] _program; + private readonly int _maxStack; + private readonly string _canonical; + + internal Formula(string text, FormulaInstruction[] program, IReadOnlyList references) + { + Text = text; + _program = program; + References = references; + MeterIds = [.. program.Where(i => i.Code == FormulaOpCode.Meter).Select(i => i.MeterId).Distinct().Order()]; + _maxStack = MaxStackDepth(program); + Root = BuildTree(program); + _canonical = Render(program); + + var linear = LinearForm.Analyze(program); + if (linear is not null && linear.Constant == 0d) + { + IsLinear = true; + Coefficients = new ReadOnlyDictionary(linear.Terms); + IsPureSum = linear.Terms.Count > 0 && linear.Terms.Values.All(c => c == 1d); + } + } + + /// The text exactly as parsed. + public string Text { get; } + + /// The root of the expression tree. + public FormulaNode Root { get; } + + /// The referenced meter ids, distinct and ascending — the derived referencedMeterIds (D-25). + public IReadOnlyList MeterIds { get; } + + /// Every m<id> token in , in order, with its position. + public IReadOnlyList References { get; } + + /// + /// True when the formula is a weighted sum of meters without a constant term: additive, so it can be applied + /// per bucket and summed, and priced as a quantity (). + /// + public bool IsLinear { get; } + + /// True for a linear formula whose every meter has weight exactly +1 (m1 + m2 + m3). + public bool IsPureSum { get; } + + /// + /// For a linear formula, each meter's weight (m1 - 2 * m2 → {1: 1, 2: −2}), + /// ordered by meter id; null otherwise. A meter that cancels out (m1 - m1) keeps a weight of 0. + /// + public IReadOnlyDictionary? Coefficients { get; } + + internal IReadOnlyList Program => _program; + + /// Parses a formula that is known to be valid; throws otherwise. + public static Formula Parse(string text) + { + var result = FormulaParser.Parse(text); + return result.Success ? result.Formula : throw new FormatException($"Invalid formula '{text}': {result.Error}."); + } + + /// The formula m<a> + m<b> + … over the given meters, in the given order, duplicates dropped. + /// + /// Throws when the text does not parse — hundreds of meters exceed + /// . Code that must not throw (a read path, an editor before saving) writes the + /// text itself and reports 's error instead. + /// + public static Formula Sum(IEnumerable meterIds) + { + ArgumentNullException.ThrowIfNull(meterIds); + + var ids = meterIds.Distinct().ToList(); + if (ids.Count == 0) + { + throw new ArgumentException("A sum needs at least one meter.", nameof(meterIds)); + } + + return Parse(string.Join(" + ", ids.Select(Token))); + } + + /// The formula m<minuend> - m<a> - m<b> … ("Difference" mode of the editor). + public static Formula Difference(int minuend, IEnumerable subtrahends) + { + ArgumentNullException.ThrowIfNull(subtrahends); + + var rest = subtrahends.ToList(); + if (rest.Count == 0) + { + throw new ArgumentException("A difference needs at least one meter to subtract.", nameof(subtrahends)); + } + + return Parse(string.Join(" - ", rest.Prepend(minuend).Select(Token))); + } + + /// + /// Evaluates the formula with each meter's value from . Arithmetic is plain IEEE: + /// a division by zero yields an infinity or NaN rather than an exception, and the caller decides what a + /// non-finite result means (the evaluator marks the bucket invalid; it never becomes a zero). + /// + public double Evaluate(Func valueOf) + { + ArgumentNullException.ThrowIfNull(valueOf); + + Span stack = _maxStack <= 128 ? stackalloc double[_maxStack] : new double[_maxStack]; + var top = 0; + foreach (var instruction in _program) + { + switch (instruction.Code) + { + case FormulaOpCode.Number: + stack[top++] = instruction.Number; + break; + case FormulaOpCode.Meter: + stack[top++] = valueOf(instruction.MeterId); + break; + case FormulaOpCode.Negate: + stack[top - 1] = -stack[top - 1]; + break; + default: + var right = stack[--top]; + var left = stack[top - 1]; + stack[top - 1] = instruction.Code switch + { + FormulaOpCode.Add => left + right, + FormulaOpCode.Subtract => left - right, + FormulaOpCode.Multiply => left * right, + _ => left / right, + }; + break; + } + } + + return stack[0]; + } + + /// + /// The same formula with every meter id passed through — for export/import, where + /// meters get new ids (D-32). is rewritten token by token, so the user's spacing and + /// parentheses survive. + /// + public Formula RewriteIds(Func map) + { + ArgumentNullException.ThrowIfNull(map); + + var mapped = new Dictionary(); + int Map(int id) + { + if (!mapped.TryGetValue(id, out var result)) + { + result = FormulaParser.MapId(map, id); + mapped[id] = result; + } + + return result; + } + + var program = _program + .Select(i => i.Code == FormulaOpCode.Meter ? i with { MeterId = Map(i.MeterId) } : i) + .ToArray(); + + var text = new StringBuilder(Text.Length + 8); + var references = new List(References.Count); + var copied = 0; + foreach (var reference in References) + { + text.Append(Text, copied, reference.Start - copied); + var token = Token(Map(reference.MeterId)); + references.Add(new FormulaReference(Map(reference.MeterId), text.Length, token.Length)); + text.Append(token); + copied = reference.Start + reference.Length; + } + + text.Append(Text, copied, Text.Length - copied); + return new Formula(text.ToString(), program, references); + } + + /// The canonical text: single spaces around binary operators, only the parentheses the structure needs. + public override string ToString() => _canonical; + + public bool Equals(Formula? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (_program.Length != other._program.Length) + { + return false; + } + + for (var i = 0; i < _program.Length; i++) + { + var (a, b) = (_program[i], other._program[i]); + if (a.Code != b.Code || a.MeterId != b.MeterId || !a.Number.Equals(b.Number)) + { + return false; + } + } + + return true; + } + + public override bool Equals(object? obj) => Equals(obj as Formula); + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var instruction in _program) + { + hash.Add(instruction.Code); + hash.Add(instruction.MeterId); + hash.Add(instruction.Number); + } + + return hash.ToHashCode(); + } + + internal static string Token(int meterId) => "m" + meterId.ToString(CultureInfo.InvariantCulture); + + private static int MaxStackDepth(FormulaInstruction[] program) + { + int depth = 0, max = 0; + foreach (var instruction in program) + { + if (instruction.Code is FormulaOpCode.Number or FormulaOpCode.Meter) + { + max = Math.Max(max, ++depth); + } + else if (instruction.IsBinary) + { + depth--; + } + } + + return max; + } + + private static FormulaNode BuildTree(FormulaInstruction[] program) + { + var stack = new Stack(); + foreach (var instruction in program) + { + switch (instruction.Code) + { + case FormulaOpCode.Number: + stack.Push(new NumberNode(instruction.Number, instruction.Literal ?? instruction.Number.ToString("R", CultureInfo.InvariantCulture))); + break; + case FormulaOpCode.Meter: + stack.Push(new MeterReferenceNode(instruction.MeterId)); + break; + case FormulaOpCode.Negate: + stack.Push(new NegateNode(stack.Pop())); + break; + default: + var right = stack.Pop(); + var left = stack.Pop(); + stack.Push(new BinaryNode(Operator(instruction.Code), left, right)); + break; + } + } + + return stack.Pop(); + } + + private static FormulaOperator Operator(FormulaOpCode code) => code switch + { + FormulaOpCode.Add => FormulaOperator.Add, + FormulaOpCode.Subtract => FormulaOperator.Subtract, + FormulaOpCode.Multiply => FormulaOperator.Multiply, + _ => FormulaOperator.Divide, + }; + + /// + /// Renders postfix to infix. Precedence: primary 4, unary 3, * / 2, + - 1. A left operand needs + /// parentheses when it binds looser than its operator; a right operand also when it binds equally, because the + /// grammar is left-associative (a - (b - c) must keep its parentheses to parse back the same). + /// + private static string Render(FormulaInstruction[] program) + { + var stack = new Stack<(string Text, int Precedence)>(); + foreach (var instruction in program) + { + switch (instruction.Code) + { + case FormulaOpCode.Number: + stack.Push((instruction.Literal ?? instruction.Number.ToString("R", CultureInfo.InvariantCulture), 4)); + break; + case FormulaOpCode.Meter: + stack.Push((Token(instruction.MeterId), 4)); + break; + case FormulaOpCode.Negate: + var operand = stack.Pop(); + stack.Push(("-" + (operand.Precedence < 3 ? $"({operand.Text})" : operand.Text), 3)); + break; + default: + var right = stack.Pop(); + var left = stack.Pop(); + var (symbol, precedence) = instruction.Code switch + { + FormulaOpCode.Add => ("+", 1), + FormulaOpCode.Subtract => ("-", 1), + FormulaOpCode.Multiply => ("*", 2), + _ => ("/", 2), + }; + var leftText = left.Precedence < precedence ? $"({left.Text})" : left.Text; + var rightText = right.Precedence <= precedence ? $"({right.Text})" : right.Text; + stack.Push(($"{leftText} {symbol} {rightText}", precedence)); + break; + } + } + + return stack.Pop().Text; + } + + /// + /// The formula as Σ weight × meter + constant, or null when it is not affine (a product or quotient of + /// meter-bearing terms, or a division by a zero constant). Whether a subterm is constant is decided by its + /// structure — it references no meter — not by its value, so (m1 - m1) * m2 stays non-linear. + /// + private sealed class LinearForm + { + private LinearForm(SortedDictionary terms, double constant) + { + Terms = terms; + Constant = constant; + } + + public SortedDictionary Terms { get; } + + public double Constant { get; private set; } + + public static LinearForm? Analyze(FormulaInstruction[] program) + { + var stack = new Stack(); + foreach (var instruction in program) + { + switch (instruction.Code) + { + case FormulaOpCode.Number: + stack.Push(new LinearForm([], instruction.Number)); + break; + case FormulaOpCode.Meter: + stack.Push(new LinearForm(new SortedDictionary { [instruction.MeterId] = 1d }, 0d)); + break; + case FormulaOpCode.Negate: + stack.Push(stack.Pop()?.Scale(-1d)); + break; + default: + var right = stack.Pop(); + var left = stack.Pop(); + stack.Push(left is null || right is null ? null : Combine(instruction.Code, left, right)); + break; + } + } + + return stack.Pop(); + } + + private static LinearForm? Combine(FormulaOpCode code, LinearForm left, LinearForm right) + { + switch (code) + { + case FormulaOpCode.Add: + return left.Add(right, 1d); + case FormulaOpCode.Subtract: + return left.Add(right, -1d); + case FormulaOpCode.Multiply when left.Terms.Count == 0 && double.IsFinite(left.Constant): + return right.Scale(left.Constant); + case FormulaOpCode.Multiply when right.Terms.Count == 0 && double.IsFinite(right.Constant): + return left.Scale(right.Constant); + case FormulaOpCode.Divide when right.Terms.Count == 0 && right.Constant != 0d && double.IsFinite(right.Constant): + return left.Scale(1d / right.Constant); + default: + return null; + } + } + + // Each subterm's form is consumed exactly once, so it is safe to update the left one in place. + private LinearForm Add(LinearForm other, double sign) + { + foreach (var (id, weight) in other.Terms) + { + Terms[id] = Terms.GetValueOrDefault(id) + (sign * weight); + } + + Constant += sign * other.Constant; + return this; + } + + private LinearForm Scale(double factor) + { + foreach (var id in Terms.Keys.ToList()) + { + Terms[id] *= factor; + } + + Constant *= factor; + return this; + } + } +} diff --git a/src/Core/Analysis/Virtual/FormulaNode.cs b/src/Core/Analysis/Virtual/FormulaNode.cs new file mode 100644 index 0000000..4992728 --- /dev/null +++ b/src/Core/Analysis/Virtual/FormulaNode.cs @@ -0,0 +1,97 @@ +namespace MeterVault.Core.Analysis.Virtual; + +/// The four binary operators of the formula grammar (D-26). +public enum FormulaOperator +{ + Add, + Subtract, + Multiply, + Divide, +} + +/// +/// A node of a parsed : a number, a meter reference, a negation or a binary operation. +/// Parentheses leave no node of their own — they only shape the tree. +/// +/// +/// The tree is immutable. It is exposed for callers that need the shape (an editor recognising "Sum" or +/// "Difference" mode, a renderer); the members — evaluation, linear analysis, the +/// canonical text — never walk it recursively. A chain such as m1 + m2 + … + m600 nests 600 levels +/// deep along its left spine even though its parenthesis depth is zero, so anyone walking this tree should +/// do it iteratively. +/// +public abstract class FormulaNode +{ + private protected FormulaNode() + { + } +} + +/// A numeric literal. is the text as written, which the canonical form keeps. +public sealed class NumberNode : FormulaNode +{ + internal NumberNode(double value, string literal) + { + Value = value; + Literal = literal; + } + + public double Value { get; } + + public string Literal { get; } +} + +/// A reference m<id> to another meter's value in the same bucket. +public sealed class MeterReferenceNode : FormulaNode +{ + internal MeterReferenceNode(int meterId) => MeterId = meterId; + + public int MeterId { get; } +} + +/// A unary minus. +public sealed class NegateNode : FormulaNode +{ + internal NegateNode(FormulaNode operand) => Operand = operand; + + public FormulaNode Operand { get; } +} + +/// A binary operation; the grammar is left-associative, so a - b - c is (a - b) - c. +public sealed class BinaryNode : FormulaNode +{ + internal BinaryNode(FormulaOperator @operator, FormulaNode left, FormulaNode right) + { + Operator = @operator; + Left = left; + Right = right; + } + + public FormulaOperator Operator { get; } + + public FormulaNode Left { get; } + + public FormulaNode Right { get; } +} + +/// One postfix instruction of a compiled formula (see ). +internal enum FormulaOpCode : byte +{ + Number, + Meter, + Negate, + Add, + Subtract, + Multiply, + Divide, +} + +/// +/// A formula compiles to postfix: operands push, operators pop. Every walk over a formula — evaluating, +/// rendering, analysing linearity, checking units — is then a loop with an explicit stack, so no input the +/// parser accepts can overflow the call stack. +/// +internal readonly record struct FormulaInstruction(FormulaOpCode Code, double Number = 0, int MeterId = 0, string? Literal = null) +{ + public bool IsBinary => Code is FormulaOpCode.Add or FormulaOpCode.Subtract or FormulaOpCode.Multiply or FormulaOpCode.Divide; +} diff --git a/src/Core/Analysis/Virtual/FormulaParser.cs b/src/Core/Analysis/Virtual/FormulaParser.cs new file mode 100644 index 0000000..f1ca3ab --- /dev/null +++ b/src/Core/Analysis/Virtual/FormulaParser.cs @@ -0,0 +1,497 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; + +namespace MeterVault.Core.Analysis.Virtual; + +/// Why a formula text was rejected. The UI localizes the kind; the position and token are data. +public enum FormulaErrorKind +{ + /// Nothing but whitespace. + Empty, + + /// Longer than characters. + TooLong, + + /// Parentheses nested deeper than . + TooDeep, + + /// A character that belongs to no token of the grammar (e.g. %). + UnexpectedCharacter, + + /// A valid token in a place the grammar does not allow it (e.g. 1 2, a stray )). + UnexpectedToken, + + /// The text stops where an operand is required (e.g. 1 +). + UnexpectedEnd, + + /// A ( that is never closed; the position is the opening parenthesis. + MissingClosingParenthesis, + + /// A run of digits and dots that is not a finite number (e.g. 1.2.3). + InvalidNumber, + + /// An identifier other than m<digits>; the token names it. + UnknownIdentifier, + + /// An m<digits> reference whose id does not fit a meter id. + MeterIdOutOfRange, +} + +/// A structured parse error: what went wrong, where (0-based index into the text), and the offending token. +public sealed record FormulaError(FormulaErrorKind Kind, int Position, string? Token = null) +{ + /// An English description for logs; the UI builds its own text from the fields. + public override string ToString() => Token is null + ? $"{Kind} at position {Position}" + : $"{Kind} '{Token}' at position {Position}"; +} + +/// The outcome of : a formula, or the first error. +public sealed record FormulaParseResult +{ + private FormulaParseResult(Formula? formula, FormulaError? error) + { + Formula = formula; + Error = error; + } + + public Formula? Formula { get; } + + public FormulaError? Error { get; } + + [MemberNotNullWhen(true, nameof(Formula))] + [MemberNotNullWhen(false, nameof(Error))] + public bool Success => Formula is not null; + + internal static FormulaParseResult Ok(Formula formula) => new(formula, null); + + internal static FormulaParseResult Fail(FormulaError error) => new(null, error); +} + +/// Where an m<id> token sits in a formula's text — lets an editor show the meter's name beside it. +public readonly record struct FormulaReference(int MeterId, int Start, int Length); + +/// +/// Parses the virtual-meter formula grammar (D-26): numbers, meter references m<id>, unary minus, +/// + - * / and parentheses. Nothing else — no functions, no names, no exponent notation. +/// +/// +/// +/// The text is user input stored in the database and evaluated on every read, so the parser is the safety +/// boundary. It never throws: every rejection is a with a position. Its limits — +/// characters and nested parentheses — are enforced while +/// parsing, and parentheses are the only thing it recurses on, so a pathological input such as 100,000 +/// opening parentheses is rejected at the 65th instead of overflowing the stack and killing the process. +/// Long operator chains and runs of unary minus are handled in loops. +/// +/// +/// The only identifiers are meter references. The evaluator it replaces quietly read any unknown name as 0, +/// so a typo like m1 - n2 produced a confident, wrong series; now it is an error naming n2. +/// References are case-sensitive (M1 is rejected) and numbers are invariant decimals (0.5, never +/// 0,5), whatever the reader's culture. +/// +/// +public static class FormulaParser +{ + /// The longest accepted formula text, in characters. + public const int MaxLength = 2000; + + /// The deepest accepted parenthesis nesting. + public const int MaxDepth = 64; + + public static FormulaParseResult Parse(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return FormulaParseResult.Fail(new FormulaError(FormulaErrorKind.Empty, 0)); + } + + if (text.Length > MaxLength) + { + return FormulaParseResult.Fail(new FormulaError(FormulaErrorKind.TooLong, MaxLength)); + } + + var parser = new Parser(text); + if (!parser.ParseExpression()) + { + return FormulaParseResult.Fail(parser.Error!); + } + + parser.SkipWhitespace(); + if (!parser.AtEnd) + { + return FormulaParseResult.Fail(parser.UnexpectedHere()); + } + + return FormulaParseResult.Ok(new Formula(text, [.. parser.Program], parser.References)); + } + + /// + /// The meter ids a text refers to — distinct, ascending — even when the text does not parse. Used where a + /// broken definition must still be traced to the meters it names (a meter-delete warning, an export). + /// + public static IReadOnlyList ScanMeterIds(string? text) + { + if (string.IsNullOrEmpty(text)) + { + return []; + } + + var ids = new SortedSet(); + foreach (var (id, _, _) in Lexer.MeterTokens(text)) + { + ids.Add(id); + } + + return [.. ids]; + } + + /// + /// Rewrites every m<id> token of through and leaves + /// every other character as the user wrote it — spacing, redundant parentheses, even a syntax error + /// elsewhere. Export/import (D-32) uses it to follow meters to their new ids. + /// + public static string RewriteMeterIds(string text, Func map) + { + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(map); + + var builder = new StringBuilder(text.Length + 8); + var copied = 0; + foreach (var (id, start, length) in Lexer.MeterTokens(text)) + { + builder.Append(text, copied, start - copied); + builder.Append('m').Append(MapId(map, id).ToString(CultureInfo.InvariantCulture)); + copied = start + length; + } + + builder.Append(text, copied, text.Length - copied); + return builder.ToString(); + } + + internal static int MapId(Func map, int id) + { + var mapped = map(id); + return mapped >= 0 + ? mapped + : throw new ArgumentOutOfRangeException(nameof(map), mapped, $"Meter id {id} was mapped to a negative id."); + } + + internal static bool IsDigit(char c) => c is >= '0' and <= '9'; + + private static bool IsNumberChar(char c) => IsDigit(c) || c == '.'; + + private static bool IsIdentifierStart(char c) => char.IsLetter(c) || c == '_'; + + private static bool IsIdentifierPart(char c) => char.IsLetterOrDigit(c) || c == '_'; + + /// True for m followed by one or more ASCII digits and nothing else. + private static bool IsMeterToken(ReadOnlySpan token) + { + if (token.Length < 2 || token[0] != 'm') + { + return false; + } + + foreach (var c in token[1..]) + { + if (!IsDigit(c)) + { + return false; + } + } + + return true; + } + + /// A forgiving scan for meter tokens, shared by and . + private static class Lexer + { + public static IEnumerable<(int Id, int Start, int Length)> MeterTokens(string text) + { + var pos = 0; + while (pos < text.Length) + { + var c = text[pos]; + if (IsIdentifierStart(c)) + { + var start = pos; + while (pos < text.Length && IsIdentifierPart(text[pos])) + { + pos++; + } + + var token = text.AsSpan(start, pos - start); + if (IsMeterToken(token) + && int.TryParse(token[1..], NumberStyles.None, CultureInfo.InvariantCulture, out var id)) + { + yield return (id, start, pos - start); + } + } + else if (IsNumberChar(c)) + { + // A number run is skipped whole, so the "3" of "1.3" is never read as part of a name. + while (pos < text.Length && IsNumberChar(text[pos])) + { + pos++; + } + } + else + { + pos++; + } + } + } + } + + /// + /// Recursive descent over expr := term (('+'|'-') term)*, term := unary (('*'|'/') unary)*, + /// unary := ('-'|'+')* primary, primary := number | m<id> | '(' expr ')', emitting postfix. + /// Only a parenthesis re-enters , and caps that. + /// + private sealed class Parser(string text) + { + private int _pos; + private int _depth; + + public List Program { get; } = []; + + public List References { get; } = []; + + public FormulaError? Error { get; private set; } + + public bool AtEnd => _pos >= text.Length; + + public bool ParseExpression() + { + if (!ParseTerm()) + { + return false; + } + + while (true) + { + SkipWhitespace(); + if (AtEnd || text[_pos] is not ('+' or '-')) + { + return true; + } + + var code = text[_pos] == '+' ? FormulaOpCode.Add : FormulaOpCode.Subtract; + _pos++; + if (!ParseTerm()) + { + return false; + } + + Program.Add(new FormulaInstruction(code)); + } + } + + public void SkipWhitespace() + { + while (_pos < text.Length && char.IsWhiteSpace(text[_pos])) + { + _pos++; + } + } + + /// The error for whatever token starts at the current position. + public FormulaError UnexpectedHere() + { + var c = text[_pos]; + if (IsNumberChar(c) || IsIdentifierStart(c) || c is '+' or '-' or '*' or '/' or '(' or ')') + { + return new FormulaError(FormulaErrorKind.UnexpectedToken, _pos, TokenAt(_pos)); + } + + return new FormulaError(FormulaErrorKind.UnexpectedCharacter, _pos, c.ToString()); + } + + private bool ParseTerm() + { + if (!ParseUnary()) + { + return false; + } + + while (true) + { + SkipWhitespace(); + if (AtEnd || text[_pos] is not ('*' or '/')) + { + return true; + } + + var code = text[_pos] == '*' ? FormulaOpCode.Multiply : FormulaOpCode.Divide; + _pos++; + if (!ParseUnary()) + { + return false; + } + + Program.Add(new FormulaInstruction(code)); + } + } + + private bool ParseUnary() + { + // A run of signs is counted, not recursed into: "------m1" costs one frame. + var negations = 0; + while (true) + { + SkipWhitespace(); + if (!AtEnd && text[_pos] == '-') + { + negations++; + _pos++; + } + else if (!AtEnd && text[_pos] == '+') + { + _pos++; + } + else + { + break; + } + } + + if (!ParsePrimary()) + { + return false; + } + + for (var i = 0; i < negations; i++) + { + Program.Add(new FormulaInstruction(FormulaOpCode.Negate)); + } + + return true; + } + + private bool ParsePrimary() + { + SkipWhitespace(); + if (AtEnd) + { + return Fail(FormulaErrorKind.UnexpectedEnd, text.Length); + } + + var c = text[_pos]; + if (c == '(') + { + var open = _pos++; + if (++_depth > MaxDepth) + { + return Fail(FormulaErrorKind.TooDeep, open, "("); + } + + if (!ParseExpression()) + { + return false; + } + + SkipWhitespace(); + if (AtEnd) + { + return Fail(FormulaErrorKind.MissingClosingParenthesis, open, "("); + } + + if (text[_pos] != ')') + { + Error = UnexpectedHere(); + return false; + } + + _pos++; + _depth--; + return true; + } + + if (IsNumberChar(c)) + { + return ParseNumber(); + } + + if (IsIdentifierStart(c)) + { + return ParseReference(); + } + + Error = UnexpectedHere(); + return false; + } + + private bool ParseNumber() + { + var start = _pos; + while (_pos < text.Length && IsNumberChar(text[_pos])) + { + _pos++; + } + + var literal = text[start.._pos]; + if (!double.TryParse(literal, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var value) + || !double.IsFinite(value)) + { + return Fail(FormulaErrorKind.InvalidNumber, start, literal); + } + + Program.Add(new FormulaInstruction(FormulaOpCode.Number, Number: value, Literal: literal)); + return true; + } + + private bool ParseReference() + { + var start = _pos; + while (_pos < text.Length && IsIdentifierPart(text[_pos])) + { + _pos++; + } + + var token = text[start.._pos]; + if (!IsMeterToken(token)) + { + return Fail(FormulaErrorKind.UnknownIdentifier, start, token); + } + + if (!int.TryParse(token.AsSpan(1), NumberStyles.None, CultureInfo.InvariantCulture, out var id)) + { + return Fail(FormulaErrorKind.MeterIdOutOfRange, start, token); + } + + Program.Add(new FormulaInstruction(FormulaOpCode.Meter, MeterId: id)); + References.Add(new FormulaReference(id, start, token.Length)); + return true; + } + + private string TokenAt(int pos) + { + var c = text[pos]; + var end = pos + 1; + if (IsNumberChar(c)) + { + while (end < text.Length && IsNumberChar(text[end])) + { + end++; + } + } + else if (IsIdentifierStart(c)) + { + while (end < text.Length && IsIdentifierPart(text[end])) + { + end++; + } + } + + return text[pos..end]; + } + + private bool Fail(FormulaErrorKind kind, int position, string? token = null) + { + Error = new FormulaError(kind, position, token); + return false; + } + } +} diff --git a/src/Core/Analysis/Virtual/LegacyVirtualDerivation.cs b/src/Core/Analysis/Virtual/LegacyVirtualDerivation.cs new file mode 100644 index 0000000..f22c3d5 --- /dev/null +++ b/src/Core/Analysis/Virtual/LegacyVirtualDerivation.cs @@ -0,0 +1,257 @@ +using System.Globalization; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Virtual; + +/// +/// What became of a legacy virtual meter (D-28). is a conversion; +/// and leave the meter alone; everything else means "needs configuration". +/// +public enum LegacyDerivationOutcome +{ + /// The incoming links named one unit and one kind; the equivalent explicit sum was derived. + Derived, + + /// + /// The meter already has a definition. An explicit expression is authoritative (brief §5.2), so topology links + /// never replace it, and a rerun over converted meters changes nothing. + /// + AlreadyDefined, + + /// The meter is unknown or not virtual; nothing to derive. + NotVirtual, + + /// No incoming link from a meter of the same energy type. + NoSources, + + /// A linked meter is not in the catalog. + UnknownSource, + + /// A linked virtual meter has no calculation itself (it could not be converted first). + SourceNeedsConfiguration, + + /// A linked meter is an indicator or a cost: a sum over it would mean nothing. + NotAdditive, + + /// The linked meters measure in different units; no conversion is invented. + MixedUnits, + + /// The linked meters measure different kinds (e.g. consumption and generation); the meaning is ambiguous. + MixedKinds, + + /// + /// The linked meters share a kind no virtual result has without a declaration (runtime, export; A-08). The value is + /// that kind. + /// + UnsupportedKind, + + /// The links loop through virtual meters; the ids are the path. + Cycle, + + /// The derived sum failed validation for another reason. + Invalid, +} + +/// The derivation for one meter. and are the data behind a "needs configuration" reason. +public sealed record LegacyDerivation( + int MeterId, + LegacyDerivationOutcome Outcome, + VirtualDefinition? Definition, + IReadOnlyList MeterIds, + IReadOnlyList Values) +{ + /// Links from meters of another energy type, which never define a same-type sum and were left out. + public IReadOnlyList IgnoredMeterIds { get; init; } = []; + + public bool IsDerived => Outcome == LegacyDerivationOutcome.Derived; + + /// True when the meter needs the user: anything but a conversion, an existing definition or a physical meter. + public bool NeedsConfiguration => + Outcome is not (LegacyDerivationOutcome.Derived or LegacyDerivationOutcome.AlreadyDefined or LegacyDerivationOutcome.NotVirtual); +} + +/// The derivations of one run, in processing (dependency) order, with the counts to log. +public sealed record LegacyDerivationRun(IReadOnlyList Results) +{ + public int Converted => Results.Count(r => r.IsDerived); + + public int NeedsConfiguration => Results.Count(r => r.NeedsConfiguration); + + /// Meters left alone: already defined, or not virtual at all. + public int Unchanged => Results.Count(r => r.Outcome is LegacyDerivationOutcome.AlreadyDefined or LegacyDerivationOutcome.NotVirtual); +} + +/// +/// Gives expression-less virtual meters the explicit definition their topology implied (D-28, brief §5.2). +/// +/// +/// +/// Before this rework a virtual meter had no stored formula; the flow view showed it as the sum of its incoming +/// links. The seeded "Summe Solar" is such a meter, fed by Solar 1 and Solar 2. That sum is only unambiguous when +/// every source measures the same unit and the same kind — two generation meters in kWh are a generation sum in kWh +/// — so only then is it written down as m<a> + m<b>. Anything else (mixed units, consumption +/// plus generation, runtime hours, no links) is left for the user and reported as needing configuration; no +/// conversion or meaning is invented. A meter that already has a definition is never touched: the expression is +/// the authority, and links are topology. +/// +/// +/// Only links from meters of the meter's own energy type count, as in the flow view: gas, district heat and +/// electricity are all kWh, so the unit check alone would not catch a gas meter linked into an electricity sum. +/// A virtual source must be converted first, so processes meters in dependency order and +/// feeds each result into the next. The derived definition is a pure sum with the default cost rule — the sources' own +/// costs for a consumption sum, none for a generation sum (D-39, A-15) — and its unit is in canonical spelling (a water +/// sum of "m3" meters is "m³"). Until a meter is +/// converted, the reader evaluates the same derivation with status "legacy — confirm". +/// +/// +public static class LegacyVirtualDerivation +{ + /// Derives the implied sum of one meter from its incoming links. + public static LegacyDerivation Derive(int meterId, IEnumerable links, MeterCatalog catalog) + { + ArgumentNullException.ThrowIfNull(links); + ArgumentNullException.ThrowIfNull(catalog); + + if (!catalog.TryGet(meterId, out var target) || !target.IsVirtual) + { + return Outcome(meterId, LegacyDerivationOutcome.NotVirtual, []); + } + + if (target.Definition is not null) + { + return Outcome(meterId, LegacyDerivationOutcome.AlreadyDefined, []); + } + + var incoming = links + .Where(l => l.ToMeterId == meterId && l.FromMeterId != meterId) + .Select(l => l.FromMeterId) + .Distinct() + .Order() + .ToList(); + + var unknown = incoming.Where(id => !catalog.Contains(id)).ToList(); + if (unknown.Count > 0) + { + return Outcome(meterId, LegacyDerivationOutcome.UnknownSource, unknown); + } + + var ignored = incoming.Where(id => catalog.Find(id)!.EnergyTypeId != target.EnergyTypeId).ToList(); + var sources = incoming.Except(ignored).Select(id => catalog.Find(id)!).ToList(); + LegacyDerivation Result(LegacyDerivationOutcome outcome, IReadOnlyList ids, params string[] values) => + Outcome(meterId, outcome, ids, values) with { IgnoredMeterIds = ignored }; + + if (sources.Count == 0) + { + return Result(LegacyDerivationOutcome.NoSources, []); + } + + var unconfigured = sources.Where(s => s.IsVirtual && s.Definition is null).Select(s => s.MeterId).ToList(); + if (unconfigured.Count > 0) + { + return Result(LegacyDerivationOutcome.SourceNeedsConfiguration, unconfigured); + } + + var nonAdditive = sources.Where(s => s.Kind is QuantityKind.Indicator or QuantityKind.Cost).Select(s => s.MeterId).ToList(); + if (nonAdditive.Count > 0) + { + return Result(LegacyDerivationOutcome.NotAdditive, nonAdditive); + } + + var ids = sources.Select(s => s.MeterId).ToList(); + + // Canonical spelling first, then the same comparison as Units.AreSame: "m3" and "M³" are one unit. + var units = sources.Select(s => Units.Normalize(s.Unit)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (units.Count > 1) + { + return Result(LegacyDerivationOutcome.MixedUnits, ids, [.. units]); + } + + var kinds = sources.Select(s => s.Kind).Distinct().ToList(); + if (kinds.Count > 1) + { + return Result(LegacyDerivationOutcome.MixedKinds, ids, [.. kinds.Select(VirtualDefinitionJson.KindToken)]); + } + + if (VirtualValidator.DefaultKind(kinds) is not { } kind) + { + return Result(LegacyDerivationOutcome.UnsupportedKind, ids, VirtualDefinitionJson.KindToken(kinds[0])); + } + + // The sum is written as text rather than built with Formula.Sum, which throws on a formula it cannot parse: + // hundreds of links make a text longer than a formula may be, and that must be this meter's finding (Syntax, + // TooLong → needs configuration), never an exception out of the catalog every read builds. + var text = string.Join(" + ", ids.Distinct().Select(id => string.Create(CultureInfo.InvariantCulture, $"m{id}"))); + var definition = new VirtualDefinition(text, kind, units[0]); + var validation = VirtualValidator.Validate(definition, meterId, catalog); + if (validation.Problems.FirstOrDefault(p => p.Kind == VirtualProblemKind.DependencyCycle) is { } cycle) + { + return Result(LegacyDerivationOutcome.Cycle, cycle.MeterIds); + } + + if (validation.EffectiveDefinition is not { } effective) + { + var problem = validation.Problems[0]; + return Result(LegacyDerivationOutcome.Invalid, problem.MeterIds, problem.Kind.ToString()); + } + + return Result(LegacyDerivationOutcome.Derived, ids) with { Definition = effective }; + } + + /// + /// Derives every meter of in dependency order: a legacy meter linked from another + /// legacy meter is processed after it, and sees its result. Only links that would use order + /// the run — a self link or a link from another energy type is not a dependency. Meters whose links loop are + /// reported as with the path. The run is pure and idempotent: the + /// caller persists the derived definitions and logs the counts, and a meter that already has a definition reports + /// , so a rerun over every virtual meter converts nothing + /// twice. + /// + public static LegacyDerivationRun DeriveAll(IEnumerable meterIds, IEnumerable links, MeterCatalog catalog) + { + ArgumentNullException.ThrowIfNull(meterIds); + ArgumentNullException.ThrowIfNull(links); + ArgumentNullException.ThrowIfNull(catalog); + + var linkList = links.ToList(); + var legacy = meterIds.Distinct().ToHashSet(); + + // Order by the links between the legacy meters themselves: a source must be converted before its sum. + var dependencies = legacy.ToDictionary( + id => id, + id => (IReadOnlyList)[.. linkList + .Where(l => l.ToMeterId == id && l.FromMeterId != id && legacy.Contains(l.FromMeterId) && SameEnergyType(catalog, l)) + .Select(l => l.FromMeterId)]); + var graph = DependencyGraph.Build(dependencies); + + var results = new List(); + var working = catalog; + foreach (var id in graph.EvaluationOrder) + { + var result = Derive(id, linkList, working); + results.Add(result); + if (result.Definition is { } definition && working.Find(id) is { } meter) + { + working = working.With(meter with + { + Definition = definition, + Kind = definition.ResultKind ?? meter.Kind, + Unit = definition.ResultUnit ?? meter.Unit, + }); + } + } + + foreach (var id in legacy.Where(id => !graph.IsEvaluable(id)).Order()) + { + results.Add(Outcome(id, LegacyDerivationOutcome.Cycle, graph.CycleFor(id) ?? [id])); + } + + return new LegacyDerivationRun(results); + } + + private static bool SameEnergyType(MeterCatalog catalog, MeterLink link) => + catalog.Find(link.FromMeterId) is { } from && catalog.Find(link.ToMeterId) is { } to && from.EnergyTypeId == to.EnergyTypeId; + + private static LegacyDerivation Outcome(int meterId, LegacyDerivationOutcome outcome, IReadOnlyList ids, params string[] values) => + new(meterId, outcome, null, ids, values); +} diff --git a/src/Core/Analysis/Virtual/MeterCatalog.cs b/src/Core/Analysis/Virtual/MeterCatalog.cs new file mode 100644 index 0000000..6654759 --- /dev/null +++ b/src/Core/Analysis/Virtual/MeterCatalog.cs @@ -0,0 +1,67 @@ +using System.Diagnostics.CodeAnalysis; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Analysis.Virtual; + +/// +/// What the virtual-meter logic needs to know about one meter: that it exists, what to call it, and what its +/// normalized series measures (D-20) — not its raw register unit. For a virtual meter, and +/// are its result kind and unit and its calculation (null while it +/// has none, i.e. an unconverted legacy meter). Units are compared through , so a +/// hand-typed "m3" meets an "m³" (A-09). +/// +/// +/// A meter whose stored definition is is not a legacy meter. Give +/// it the repairable definition returned, or leave it out of a +/// legacy derivation run. With a null it would look expression-less and could be overwritten +/// by a sum derived from its links. +/// +public sealed record CatalogMeter( + int MeterId, + string Name, + MeterMode Mode, + QuantityKind Kind, + string Unit, + short EnergyTypeId = 0, + VirtualDefinition? Definition = null, + DateOnly? InstalledAt = null, + DateOnly? RetiredAt = null) +{ + public bool IsVirtual => Mode == MeterMode.Virtual; +} + +/// +/// The meters a definition may refer to, by id. Built once per request (or per save) by the caller from the +/// meter table and each meter's normalized quantity; the virtual-meter logic itself stays free of storage. +/// +public sealed class MeterCatalog +{ + private readonly Dictionary _meters; + + public MeterCatalog(IEnumerable meters) + { + ArgumentNullException.ThrowIfNull(meters); + + _meters = []; + foreach (var meter in meters) + { + _meters[meter.MeterId] = meter; + } + } + + public IReadOnlyCollection Meters => _meters.Values; + + public bool Contains(int meterId) => _meters.ContainsKey(meterId); + + public bool TryGet(int meterId, [NotNullWhen(true)] out CatalogMeter? meter) => _meters.TryGetValue(meterId, out meter); + + public CatalogMeter? Find(int meterId) => _meters.GetValueOrDefault(meterId); + + /// A copy with added or replaced — e.g. after a legacy meter got its definition. + public MeterCatalog With(CatalogMeter meter) + { + ArgumentNullException.ThrowIfNull(meter); + + return new MeterCatalog(_meters.Values.Where(m => m.MeterId != meter.MeterId).Append(meter)); + } +} diff --git a/src/Core/Analysis/Virtual/VirtualDefinition.cs b/src/Core/Analysis/Virtual/VirtualDefinition.cs new file mode 100644 index 0000000..b84dd88 --- /dev/null +++ b/src/Core/Analysis/Virtual/VirtualDefinition.cs @@ -0,0 +1,104 @@ +namespace MeterVault.Core.Analysis.Virtual; + +/// +/// How a virtual meter is costed (D-39). The rule is named next to every virtual cost, because the two ways of +/// pricing differ whenever the sources have different tariffs. +/// +public enum VirtualCostRule +{ + /// Analysis only: the meter has no cost of its own. + None, + + /// The sum of the sources' own metered costs. Only for pure sums; excludes scope standing charges. + SourceCosts, + + /// The virtual quantity priced with the normal tariff precedence. Only for linear formulas. + OwnQuantity, +} + +/// +/// A virtual meter's canonical definition (D-25), stored in Meter.Meta by . +/// +/// +/// +/// The expression is the only authority for what the meter computes. is derived +/// from it here, never read back from storage, so the stored id list cannot drift from the formula; topology links +/// never define a calculation either (brief §5.1). +/// +/// +/// , and are null when not declared. Then +/// infers them where that is unambiguous (a sum of generation meters is generation) +/// and reports that a declaration is required where it is not. A save stores the effective values +/// (, A-08), so a stored definition leaves nothing undeclared. The +/// expression is parsed once, when it is set, so a definition can be read repeatedly without re-parsing; +/// with { Expression = … } re-parses. +/// +/// +public sealed record VirtualDefinition +{ + private readonly string _expression = string.Empty; + private readonly FormulaParseResult _parsed = FormulaParser.Parse(string.Empty); + + public VirtualDefinition( + string expression, + QuantityKind? resultKind = null, + string? resultUnit = null, + VirtualCostRule? costRule = null) + { + Expression = expression; + ResultKind = resultKind; + ResultUnit = resultUnit; + CostRule = costRule; + } + + /// The formula text (D-26), e.g. m4 + m5. + public string Expression + { + get => _expression; + init + { + _expression = value ?? string.Empty; + _parsed = FormulaParser.Parse(_expression); + } + } + + /// What the result measures; null = infer from the sources. + public QuantityKind? ResultKind { get; init; } + + /// The result's unit; null = the sources' common unit. Required for indicators built from products. + public string? ResultUnit { get; init; } + + /// How the meter is costed; null = . + public VirtualCostRule? CostRule { get; init; } + + /// + /// The declared result as the quantity module reads it (D-20, ), or null + /// while the kind is not declared. A stored definition always declares it (A-08). So does a legacy derivation's + /// sum, so a not-yet-converted Summe Solar is still labelled generation, not consumption. + /// + public Quantities.DeclaredVirtualResult? DeclaredResult => + ResultKind is { } kind ? new Quantities.DeclaredVirtualResult(kind, ResultUnit) : null; + + /// The parsed expression, or its first syntax error. + public FormulaParseResult Parsed => _parsed; + + /// The parsed formula, or null when the expression does not parse. + public Formula? Formula => _parsed.Formula; + + /// + /// The meters the expression refers to, distinct and ascending. For an expression that does not parse, the + /// m<id> tokens it still contains — so a broken definition keeps its dependents traceable. + /// + public IReadOnlyList ReferencedMeterIds => + _parsed.Formula?.MeterIds ?? FormulaParser.ScanMeterIds(_expression); + + public bool Equals(VirtualDefinition? other) => + other is not null + && string.Equals(Expression, other.Expression, StringComparison.Ordinal) + && ResultKind == other.ResultKind + && string.Equals(ResultUnit, other.ResultUnit, StringComparison.Ordinal) + && CostRule == other.CostRule; + + public override int GetHashCode() => + HashCode.Combine(StringComparer.Ordinal.GetHashCode(Expression), ResultKind, ResultUnit, CostRule); +} diff --git a/src/Core/Analysis/Virtual/VirtualDefinitionJson.cs b/src/Core/Analysis/Virtual/VirtualDefinitionJson.cs new file mode 100644 index 0000000..1ca5bad --- /dev/null +++ b/src/Core/Analysis/Virtual/VirtualDefinitionJson.cs @@ -0,0 +1,350 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using MeterVault.Core.Analysis.Quantities; + +namespace MeterVault.Core.Analysis.Virtual; + +/// What found in a meter's Meta. +public enum VirtualDefinitionReadStatus +{ + /// No expression is stored (blank Meta, no expression key, or a blank one): a legacy meter (D-28). + Absent, + + /// An expression and valid metadata. The formula itself may still fail to parse — that is the validator's call. + Present, + + /// The stored JSON cannot be trusted: unparsable, not an object, or a key of the wrong type or value. + Malformed, +} + +/// +/// The result of reading a definition. For , +/// is still filled when the expression itself could be read, so an editor can offer it for repair; a reader must +/// treat the meter as invalid all the same. +/// +public sealed record VirtualDefinitionReadResult( + VirtualDefinitionReadStatus Status, + VirtualDefinition? Definition, + string? Problem = null) +{ + /// + /// True when the stored referencedMeterIds disagree with the ids derived from the expression. The + /// expression wins; a save rewrites the list. + /// + public bool ReferencedIdsStale { get; init; } +} + +/// +/// Reads and writes a virtual meter's definition in Meter.Meta (D-25) under the keys expression, +/// referencedMeterIds, resultKind, resultUnit and costRule. +/// +/// +/// +/// Meta is shared free-form JSON: the meter's role lives there too, and future keys may. Writing touches +/// only these five keys and keeps every other key, its value and its position. Reading never throws — a +/// definition is read on every analysis request, and one bad blob must turn into an "invalid definition" notice +/// for that meter, not a failed page (the tolerance of , made explicit as a +/// result). +/// +/// +/// Tokens are lower camel case and read case-insensitively: consumption|generation|net|indicator (A-08: no other +/// kind is a virtual result, so "runtime" or "cost" reads as malformed) and none|sourceCosts|ownQuantity. +/// +/// +/// What is written is complete: the kind, the cost rule and the canonical unit are always stored (A-08), taken from +/// . Readers — the quantity of a meter (D-20), the totals policy +/// (D-22), the bill (D-39) — then take them as they are. Inferring them would need the whole catalog validated in +/// dependency order first. +/// +/// +public static class VirtualDefinitionJson +{ + public const string ExpressionKey = "expression"; + public const string ReferencedMeterIdsKey = "referencedMeterIds"; + public const string ResultKindKey = "resultKind"; + public const string ResultUnitKey = "resultUnit"; + public const string CostRuleKey = "costRule"; + + private static readonly string[] Keys = [ExpressionKey, ReferencedMeterIdsKey, ResultKindKey, ResultUnitKey, CostRuleKey]; + + public static VirtualDefinitionReadResult Read(string? metaJson) + { + if (string.IsNullOrWhiteSpace(metaJson)) + { + return new VirtualDefinitionReadResult(VirtualDefinitionReadStatus.Absent, null); + } + + try + { + using var doc = JsonDocument.Parse(metaJson); + return Read(doc.RootElement); + } + catch (JsonException) + { + return new VirtualDefinitionReadResult(VirtualDefinitionReadStatus.Malformed, null, "Meta is not valid JSON"); + } + catch (Exception ex) when (ex is InvalidOperationException or ArgumentException) + { + // Valid JSON can still hold text .NET cannot decode, such as a lone surrogate ("\ud800"). jsonb refuses it, + // but an import file does not. + return new VirtualDefinitionReadResult(VirtualDefinitionReadStatus.Malformed, null, "Meta holds text that cannot be decoded"); + } + } + + /// + /// Returns with the definition's keys set — referencedMeterIds derived + /// from the expression, the unit in canonical spelling — and every other key kept. Meta that is not a JSON object + /// cannot be merged into and is replaced by one. + /// + /// The meter's current Meta. + /// + /// The effective definition (): kind and cost rule declared, the + /// kind one a virtual meter may have. A blank unit (sources without a unit) is left out. + /// + /// The definition leaves its kind or cost rule to inference, or has a kind no virtual meter may have. + public static string Write(string? existingMetaJson, VirtualDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + + if (definition.ResultKind is not { } kind || !DeclaredVirtualResult.AllowedKinds.Contains(kind)) + { + throw new ArgumentException( + "Only an effective definition is stored: its result kind must be consumption, generation, net or indicator.", + nameof(definition)); + } + + if (definition.CostRule is not { } rule) + { + throw new ArgumentException("Only an effective definition is stored: its cost rule must be set.", nameof(definition)); + } + + var unit = Units.Normalize(definition.ResultUnit); + var root = ParseObject(existingMetaJson); + root[ExpressionKey] = definition.Expression; + root[ReferencedMeterIdsKey] = new JsonArray([.. definition.ReferencedMeterIds.Select(id => (JsonNode?)id)]); + root[ResultKindKey] = KindToken(kind); + SetOrRemove(root, ResultUnitKey, unit.Length == 0 ? null : unit); + root[CostRuleKey] = CostRuleToken(rule); + return root.ToJsonString(); + } + + /// Removes the definition's keys, keeping every other key (a meter that stops being virtual). + public static string Remove(string? existingMetaJson) + { + var root = ParseObject(existingMetaJson); + foreach (var key in Keys) + { + root.Remove(key); + } + + return root.ToJsonString(); + } + + /// + /// Rewrites the meter ids inside a stored definition (D-32: export/import renumbers meters). The expression is + /// rewritten token by token — even one that does not parse — and referencedMeterIds re-derived. Meta + /// without an expression, or JSON that is not an object, is returned unchanged. + /// + public static string RewriteMeterIds(string? metaJson, Func map) + { + ArgumentNullException.ThrowIfNull(map); + + if (string.IsNullOrWhiteSpace(metaJson) || TryParseObject(metaJson) is not { } root + || !root.TryGetPropertyValue(ExpressionKey, out var node) + || node is not JsonValue value || !value.TryGetValue(out var expression)) + { + return metaJson ?? string.Empty; + } + + var rewritten = FormulaParser.RewriteMeterIds(expression, map); + root[ExpressionKey] = rewritten; + root[ReferencedMeterIdsKey] = new JsonArray([.. new VirtualDefinition(rewritten).ReferencedMeterIds.Select(id => (JsonNode?)id)]); + return root.ToJsonString(); + } + + /// The token of any kind: stored for a result kind, and used to name a source's kind in a validation problem. + public static string KindToken(QuantityKind kind) => kind switch + { + QuantityKind.Consumption => "consumption", + QuantityKind.Generation => "generation", + QuantityKind.Export => "export", + QuantityKind.Runtime => "runtime", + QuantityKind.Net => "net", + QuantityKind.Indicator => "indicator", + QuantityKind.Cost => "cost", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; + + /// + /// Parses a stored resultKind token. Only the kinds a virtual meter may have are tokens here (A-08); any + /// other word, including another kind's name, is not. + /// + public static bool TryParseKind(string? token, out QuantityKind kind) + { + foreach (var candidate in DeclaredVirtualResult.AllowedKinds) + { + if (string.Equals(token?.Trim(), KindToken(candidate), StringComparison.OrdinalIgnoreCase)) + { + kind = candidate; + return true; + } + } + + kind = default; + return false; + } + + public static string CostRuleToken(VirtualCostRule rule) => rule switch + { + VirtualCostRule.None => "none", + VirtualCostRule.SourceCosts => "sourceCosts", + VirtualCostRule.OwnQuantity => "ownQuantity", + _ => throw new ArgumentOutOfRangeException(nameof(rule), rule, null), + }; + + public static bool TryParseCostRule(string? token, out VirtualCostRule rule) + { + foreach (var candidate in Enum.GetValues()) + { + if (string.Equals(token?.Trim(), CostRuleToken(candidate), StringComparison.OrdinalIgnoreCase)) + { + rule = candidate; + return true; + } + } + + rule = default; + return false; + } + + private static VirtualDefinitionReadResult Read(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) + { + return new VirtualDefinitionReadResult(VirtualDefinitionReadStatus.Malformed, null, "Meta is not a JSON object"); + } + + if (!root.TryGetProperty(ExpressionKey, out var expressionElement) + || expressionElement.ValueKind == JsonValueKind.Null + || (expressionElement.ValueKind == JsonValueKind.String && string.IsNullOrWhiteSpace(expressionElement.GetString()))) + { + return new VirtualDefinitionReadResult(VirtualDefinitionReadStatus.Absent, null); + } + + if (expressionElement.ValueKind != JsonValueKind.String) + { + return new VirtualDefinitionReadResult(VirtualDefinitionReadStatus.Malformed, null, $"'{ExpressionKey}' is not a string"); + } + + var problems = new List(); + QuantityKind? kind = null; + if (OptionalString(root, ResultKindKey, problems) is { } kindToken) + { + if (TryParseKind(kindToken, out var parsedKind)) + { + kind = parsedKind; + } + else + { + problems.Add($"'{ResultKindKey}' has an unknown value"); + } + } + + var unit = OptionalString(root, ResultUnitKey, problems); + + VirtualCostRule? costRule = null; + if (OptionalString(root, CostRuleKey, problems) is { } ruleToken) + { + if (TryParseCostRule(ruleToken, out var parsedRule)) + { + costRule = parsedRule; + } + else + { + problems.Add($"'{CostRuleKey}' has an unknown value"); + } + } + + var definition = new VirtualDefinition(expressionElement.GetString()!, kind, unit, costRule); + var stale = !StoredIdsMatch(root, definition.ReferencedMeterIds); + return problems.Count == 0 + ? new VirtualDefinitionReadResult(VirtualDefinitionReadStatus.Present, definition) { ReferencedIdsStale = stale } + : new VirtualDefinitionReadResult(VirtualDefinitionReadStatus.Malformed, definition, string.Join("; ", problems)) { ReferencedIdsStale = stale }; + } + + /// A string property, trimmed; null when absent, JSON null or blank. A non-string value is recorded as a problem. + private static string? OptionalString(JsonElement root, string key, List problems) + { + if (!root.TryGetProperty(key, out var element) || element.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (element.ValueKind != JsonValueKind.String) + { + problems.Add($"'{key}' is not a string"); + return null; + } + + var text = element.GetString(); + return string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + } + + private static bool StoredIdsMatch(JsonElement root, IReadOnlyList derived) + { + if (!root.TryGetProperty(ReferencedMeterIdsKey, out var ids) || ids.ValueKind != JsonValueKind.Array) + { + return false; + } + + var stored = new SortedSet(); + foreach (var element in ids.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.Number || !element.TryGetInt32(out var id)) + { + return false; + } + + stored.Add(id); + } + + return stored.SequenceEqual(derived); + } + + private static void SetOrRemove(JsonObject root, string key, string? value) + { + if (value is null) + { + root.Remove(key); + } + else + { + root[key] = value; + } + } + + private static JsonObject ParseObject(string? json) => + (string.IsNullOrWhiteSpace(json) ? null : TryParseObject(json)) ?? new JsonObject(); + + /// + /// The JSON as a mutable object, or null when it is not one. JsonNode materializes properties lazily, and a + /// duplicate key only fails there — so the object is forced here, inside the guard. + /// + private static JsonObject? TryParseObject(string json) + { + try + { + if (JsonNode.Parse(json) is not JsonObject root) + { + return null; + } + + _ = root.Count; + return root; + } + catch (Exception ex) when (ex is JsonException or ArgumentException or InvalidOperationException) + { + return null; + } + } +} diff --git a/src/Core/Analysis/Virtual/VirtualEvaluator.cs b/src/Core/Analysis/Virtual/VirtualEvaluator.cs new file mode 100644 index 0000000..7128e62 --- /dev/null +++ b/src/Core/Analysis/Virtual/VirtualEvaluator.cs @@ -0,0 +1,720 @@ +namespace MeterVault.Core.Analysis.Virtual; + +/// +/// One source meter's figures for one local day: the amount booked that day, whether the day lies inside the +/// meter's coverage (D-13), the coverage run's resolution, and the provenance of the amount. +/// +/// +/// An uncovered day is unknown even when it carries an amount — an opening balance with an unknown start is booked +/// on a day but covers no time (D-14), and a strict sum must not take it as that day's use. +/// +/// +/// The flag of the run covering the day: true when the source's amounts add +/// up to exact local-month totals. Only week and month resolution need it. Hour and day amounts are exact per day, +/// and a coarse interval is never divided. With the flag, joint coverage may cut the source at a month boundary. +/// Without it, the source's intervals may straddle the boundary, so any cut splits an interval. The default is the +/// strict reading. +/// +public readonly record struct SourceDay( + double Amount, + bool Covered, + ResolutionClass Resolution, + Provenance Provenance, + bool DividedAtMonths = false); + +/// +/// A source series as the evaluator consumes it: day figures over the requested range plus the per-bucket status +/// the coverage logic already worked out (D-14). The evaluator derives joint coverage from the days. From the bucket +/// states it takes only what days cannot tell: , +/// , and +/// (for example, coverage that ends mid-day), each with its issue. +/// +/// The source meter. +/// Day figures; a day that is absent is not covered. +public sealed record VirtualSource(int MeterId, IReadOnlyDictionary Days) +{ + /// + /// The source's own state per bucket, one per evaluated bucket and in the same order; null when every bucket is + /// plain. A list of another length is refused, because states are matched to buckets by position. + /// + public IReadOnlyList? BucketStates { get; init; } + + /// + /// The buckets were computed for, when known (a nested evaluation). They must equal the + /// evaluated buckets. A nested meter evaluated by month cannot feed a parent evaluated by day: its January value + /// would land on 1 January. + /// + public IReadOnlyList? Buckets { get; init; } + + /// + /// The source's state over the whole range. Required when contains + /// . An unresolved bucket says nothing about the range: a monthly source is + /// unresolved for every day of January, yet resolves January as a whole. When null, the state is derived from + /// as pending or invalid anywhere, because either spoils the total. + /// + public BucketValue? PeriodState { get; init; } + + /// A state that applies to every bucket — a nested virtual meter on a dependency loop, or still being built. + public BucketValue? Failure { get; init; } + + /// Before this local date the meter did not exist: an uncovered day then counts as a known zero (D-24). + public DateOnly? InstalledAt { get; init; } + + /// After this local date the meter no longer exists: an uncovered day then counts as a known zero (D-24). + public DateOnly? RetiredAt { get; init; } + + /// + /// False for a non-additive nested meter (a ratio, or any indicator): its day values do not add up to its bucket + /// value, so it enters a formula with its own bucket and period values (, + /// ), and its days only tell where it is covered. + /// + public bool IsAdditive { get; init; } = true; + + /// + /// A source whose every bucket has (e.g. a nested meter on a loop), for any bucket count. + /// starts at the source itself, as returns + /// it. + /// + public static VirtualSource Failed(int meterId, BucketStatus status, ValueIssue issue, IReadOnlyList? dependencyPath = null) => + new(meterId, new Dictionary()) + { + Failure = new BucketValue(null, status, Provenance.None, issue, null, dependencyPath), + }; + + /// + /// A nested virtual meter's evaluation as a source of another formula: its per-day values over its joint + /// coverage, its buckets, and its bucket and period states. A missing leaf or a division by zero two levels down + /// then reaches the outer meter with the path to it. + /// + public static VirtualSource FromEvaluation(VirtualEvaluation evaluation) + { + ArgumentNullException.ThrowIfNull(evaluation); + + return new VirtualSource(evaluation.MeterId, evaluation.Days) + { + BucketStates = evaluation.Values, + Buckets = evaluation.Buckets, + PeriodState = evaluation.Total, + IsAdditive = evaluation.IsAdditive, + }; + } + + /// True when lies outside the meter's lifecycle. + public bool IsOutsideLifecycle(DateOnly day) => + (InstalledAt is { } installed && day < installed) || (RetiredAt is { } retired && day > retired); +} + +/// +/// One source's part in a virtual result. is the source's own series (its own coverage and +/// status — "B is missing in February"); is what entered the formula: the source's +/// amount over the days all sources cover, null where nothing did. For a linear formula, +/// × used amount is the source's share of the result. +/// +public sealed record VirtualContribution( + int MeterId, + double? Coefficient, + IReadOnlyList Values, + IReadOnlyList UsedAmounts, + BucketValue Total, + double? UsedTotal); + +/// A virtual meter evaluated over a set of buckets (D-27). +/// The evaluated virtual meter; every dependency path in the result starts here. +/// The evaluated buckets. +/// One value per bucket. +/// The period total: the formula applied to the sources' totals over their joint coverage. +/// +/// True for a linear formula over additive sources, unless the result is an indicator: the available buckets then +/// add up to the total. False marks the series non-additive. The sum of monthly ratios is not the ratio of the year, +/// and an indicator is never totalled (D-26), so a chart must not offer to add the buckets up. +/// +/// Every source's own series and the amounts it put into the formula. +/// +/// The per-day result on every jointly covered day, for use as a source of another formula. For a non-additive +/// result the days mark coverage only; their values are not meant to be summed. +/// +public sealed record VirtualEvaluation( + int MeterId, + IReadOnlyList Buckets, + IReadOnlyList Values, + BucketValue Total, + bool IsAdditive, + IReadOnlyList Contributions, + IReadOnlyDictionary Days) +{ + /// The number of local days the buckets span. + public int RangeDays { get; init; } + + /// The number of those days every source covers — the virtual meter's coverage. + public int JointDays { get; init; } + + /// The first jointly covered day, for "covered from … to …"; null when none is. + public DateOnly? FirstJointDay { get; init; } + + /// The last jointly covered day; null when none is. + public DateOnly? LastJointDay { get; init; } + + /// The coarsest resolution among the sources on the jointly covered days; null when none is covered. + public ResolutionClass? Resolution { get; init; } +} + +/// +/// Evaluates a virtual meter on read, bucket by bucket, from its sources' day figures (D-27). +/// +/// +/// +/// Missing is not zero. A day counts only when every source covers it — the joint coverage, the +/// intersection of the sources' coverage. A bucket where some days are jointly covered is partial (and keeps its +/// partial value); where none are, it is missing, naming the source that is absent. The function this replaces put +/// a 0 wherever one source had no row, so "A + B" with B missing for February reported A's February as a confident +/// total. An observed zero is a valid input like any other number, and so is the known zero of a meter outside its +/// installed lifetime (D-24): a sum over a meter retired in June and its successor installed in July stays +/// complete. +/// +/// +/// Joint coverage must not cut an interval. A monthly source books its month on one day. When another source +/// covers only part of that month, the joint days keep the whole month of the first source against a few days of the +/// other: "m1 − m2" would be a month minus 22 days. So when joint coverage cuts a coarse source inside one of its +/// intervals, the bucket (or the period total) is unresolved, naming that source. Hour and day sources are exact per +/// day. Week and month sources divided at local months () may be cut at a +/// month boundary. That keeps "B missing in February" a partial January total. Anything else is cut only between its +/// covered stretches. +/// +/// +/// Values come from totals. A bucket's value is the formula applied to each source's total over the bucket's +/// jointly covered days, and the period total is the same over the whole range. For a linear formula that equals +/// the sum of per-day values, and the buckets add up to the total. For a non-linear one (m1 / m2) it is the +/// ratio of the totals — the only reading that does not change when the chart is zoomed — and the result is flagged +/// non-additive. A non-finite result (a division by zero) makes the bucket invalid with that reason; it never becomes +/// a zero or an infinity on a chart. +/// +/// +/// What days cannot say, the bucket states do. A source that only resolves months cannot answer a day bucket +/// (unresolved). A nested meter may be invalid or still being built (pending). Coverage may end mid-day, which makes +/// the day partial although it holds a row. Those states are taken from the source as given and outrank joint +/// coverage in this order: pending, invalid, missing, unresolved, partial. Every issue that comes from a source +/// carries the from this meter down to the cause. +/// +/// +public static class VirtualEvaluator +{ + /// + /// Evaluates virtual meter 's over + /// (contiguous, non-overlapping, as the period resolver produces them). is the + /// meter's effective result kind: an is never additive, whatever its formula. + /// A referenced meter absent from has no coverage. + /// + /// + /// A source's bucket states do not line up with , or it reports unresolved buckets without a + /// period state. + /// + public static VirtualEvaluation Evaluate( + int meterId, + Formula formula, + QuantityKind resultKind, + IReadOnlyList buckets, + IEnumerable sources) + { + ArgumentNullException.ThrowIfNull(formula); + ArgumentNullException.ThrowIfNull(buckets); + ArgumentNullException.ThrowIfNull(sources); + + var byId = new Dictionary(); + foreach (var source in sources) + { + byId[source.MeterId] = source; + } + + var inputs = formula.MeterIds + .Select(id => byId.GetValueOrDefault(id) ?? new VirtualSource(id, new Dictionary())) + .ToArray(); + foreach (var input in inputs) + { + CheckAligned(input, buckets); + } + + if (inputs.Length == 0) + { + return Unevaluable(meterId, buckets); + } + + var run = new Run(meterId, formula, inputs); + var values = new BucketValue[buckets.Count]; + var perSource = inputs.Select(_ => (Values: new BucketValue[buckets.Count], Used: new double?[buckets.Count])).ToArray(); + var total = new Accumulator(inputs.Length); + + for (var b = 0; b < buckets.Count; b++) + { + var bucket = new Accumulator(inputs.Length); + for (var day = buckets[b].FirstDay; day < buckets[b].EndDay; day = day.AddDays(1)) + { + run.AddDay(day, bucket, total); + } + + var states = inputs.Select(s => s.Failure ?? s.BucketStates?[b]).ToArray(); + var amounts = run.Amounts(bucket, states); + values[b] = run.Resolve(bucket, states, amounts); + for (var s = 0; s < inputs.Length; s++) + { + perSource[s].Values[b] = OwnValue(bucket, s, states[s]); + perSource[s].Used[b] = UsedAmount(values[b], bucket, amounts[s]); + } + } + + var periodStates = inputs.Select(s => s.Failure ?? s.PeriodState ?? DerivePeriodState(s.BucketStates)).ToArray(); + var totalAmounts = run.Amounts(total, periodStates); + var totalValue = run.Resolve(total, periodStates, totalAmounts); + + var contributions = inputs.Select((s, i) => new VirtualContribution( + s.MeterId, + formula.Coefficients?.GetValueOrDefault(s.MeterId), + perSource[i].Values, + perSource[i].Used, + OwnValue(total, i, periodStates[i]), + UsedAmount(totalValue, total, totalAmounts[i]))).ToList(); + + var additive = resultKind != QuantityKind.Indicator && formula.IsLinear && inputs.All(s => s.IsAdditive); + return new VirtualEvaluation(meterId, buckets, values, totalValue, additive, contributions, run.Days) + { + RangeDays = total.Days, + JointDays = total.JointDays, + FirstJointDay = total.FirstJointDay, + LastJointDay = total.LastJointDay, + Resolution = total.Resolution, + }; + } + + /// + /// Refuses bucket states that cannot be matched to the evaluated buckets, and unresolved states without a period + /// state. Either would put a wrong number on a chart without a trace. + /// + private static void CheckAligned(VirtualSource source, IReadOnlyList buckets) + { + if (source.Failure is not null) + { + return; + } + + if (source.Buckets is { } own && !own.SequenceEqual(buckets)) + { + throw new ArgumentException( + $"Source m{source.MeterId} was evaluated over other buckets ({own.Count}) than the ones requested ({buckets.Count}).", + nameof(buckets)); + } + + if (source.BucketStates is not { } states) + { + return; + } + + if (states.Count != buckets.Count) + { + throw new ArgumentException( + $"Source m{source.MeterId} reports {states.Count} bucket states for {buckets.Count} buckets.", nameof(buckets)); + } + + if (source.PeriodState is null && states.Any(s => s.Status == BucketStatus.Unresolved)) + { + throw new ArgumentException( + $"Source m{source.MeterId} reports unresolved buckets but no period state; an unresolved bucket says nothing about the whole range.", + nameof(buckets)); + } + } + + /// Pending or invalid anywhere spoils the total; nothing else about the range follows from bucket states. + private static BucketValue? DerivePeriodState(IReadOnlyList? states) => + states?.FirstOrDefault(s => s.Status == BucketStatus.Pending) + ?? states?.FirstOrDefault(s => s.Status == BucketStatus.Invalid); + + /// A formula without meters has no coverage to speak of: every bucket is an invalid definition. + private static VirtualEvaluation Unevaluable(int meterId, IReadOnlyList buckets) + { + var invalid = new BucketValue(null, BucketStatus.Invalid, Provenance.Derived, ValueIssue.InvalidDefinition); + return new VirtualEvaluation( + meterId, buckets, [.. buckets.Select(_ => invalid)], invalid, false, [], new Dictionary()) + { + RangeDays = buckets.Sum(b => Math.Max(0, b.EndDay.DayNumber - b.FirstDay.DayNumber)), + }; + } + + /// The source's own value over its own coverage, or the state it reported. + private static BucketValue OwnValue(Accumulator acc, int s, BucketValue? reported) + { + var state = reported is not null && !acc.AllLifecycle(s) ? reported : null; + if (state is not null) + { + if (state.Status is BucketStatus.Pending or BucketStatus.Invalid or BucketStatus.Unresolved) + { + return state; + } + + // A nested virtual meter reports its own (possibly non-additive) bucket value; prefer it to a day sum. + if (state is { Value: not null, Status: BucketStatus.Available or BucketStatus.Partial }) + { + return state; + } + } + + if (acc.OwnDays[s] == 0) + { + return BucketValue.Missing(); + } + + if (acc.OwnDays[s] < acc.Days) + { + return new BucketValue(acc.OwnSums[s], BucketStatus.Partial, acc.OwnProvenance[s], ValueIssue.PartialCoverage); + } + + return state is { Status: BucketStatus.Partial } + ? new BucketValue(acc.OwnSums[s], BucketStatus.Partial, acc.OwnProvenance[s], OrPartial(state.Issue), state.IssueDetail, state.DependencyPath) + : BucketValue.Available(acc.OwnSums[s], acc.OwnProvenance[s]); + } + + private static ValueIssue OrPartial(ValueIssue issue) => issue == ValueIssue.None ? ValueIssue.PartialCoverage : issue; + + private static double? UsedAmount(BucketValue result, Accumulator acc, double amount) => + acc.JointDays > 0 + && (result.Status is BucketStatus.Available or BucketStatus.Partial + || result is { Status: BucketStatus.Invalid, Issue: ValueIssue.NonFinite }) + ? amount + : null; + + /// The fixed inputs of one evaluation, and the per-day results it produces along the way. + private sealed class Run + { + private readonly int _meterId; + private readonly Formula _formula; + private readonly VirtualSource[] _inputs; + private readonly Dictionary _index; + + // Scratch for the day being added, reused across days. + private readonly DayState[] _states; + private readonly SourceDay[] _entries; + private readonly double[] _dayValues; + + public Run(int meterId, Formula formula, VirtualSource[] inputs) + { + _meterId = meterId; + _formula = formula; + _inputs = inputs; + _index = inputs.Select((s, i) => (s.MeterId, i)).ToDictionary(p => p.MeterId, p => p.i); + _states = new DayState[inputs.Length]; + _entries = new SourceDay[inputs.Length]; + _dayValues = new double[inputs.Length]; + } + + public Dictionary Days { get; } = []; + + /// Adds one day to its bucket and to the period total, which sees every day so it can follow intervals across buckets. + public void AddDay(DateOnly day, Accumulator bucket, Accumulator total) + { + var joint = true; + var divided = true; + var provenance = Provenance.Derived; + var resolution = ResolutionClass.Hour; + for (var s = 0; s < _inputs.Length; s++) + { + var source = _inputs[s]; + if (source.Failure is null && source.Days.TryGetValue(day, out var entry) && entry.Covered) + { + _states[s] = DayState.Covered; + _entries[s] = entry; + _dayValues[s] = entry.Amount; + provenance |= entry.Provenance; + resolution = (ResolutionClass)Math.Max((int)resolution, (int)entry.Resolution); + divided &= entry.Resolution <= ResolutionClass.Day || entry.DividedAtMonths; + } + else if (source.IsOutsideLifecycle(day)) + { + _states[s] = DayState.Lifecycle; + _dayValues[s] = 0d; + } + else + { + _states[s] = DayState.Unknown; + joint = false; + } + } + + bucket.Add(day, _states, _entries, _dayValues, joint, provenance, resolution); + total.Add(day, _states, _entries, _dayValues, joint, provenance, resolution); + if (!joint) + { + return; + } + + var values = _dayValues; + var index = _index; + Days[day] = new SourceDay(_formula.Evaluate(id => values[index[id]]), true, resolution, provenance, divided); + } + + /// + /// What each source puts into the formula: its total over the jointly covered days, or for a non-additive + /// nested meter its own bucket value. + /// + public double[] Amounts(Accumulator acc, BucketValue?[] states) + { + var amounts = (double[])acc.JointSums.Clone(); + for (var s = 0; s < _inputs.Length; s++) + { + if (!_inputs[s].IsAdditive && states[s] is { Value: { } value, Status: BucketStatus.Available or BucketStatus.Partial }) + { + amounts[s] = value; + } + } + + return amounts; + } + + public BucketValue Resolve(Accumulator acc, BucketValue?[] states, double[] amounts) + { + // States a source reports for the bucket, unless the bucket lies wholly outside its lifetime. + int pending = -1, invalid = -1, unresolved = -1, partial = -1; + for (var s = states.Length - 1; s >= 0; s--) + { + if (states[s] is not { } state || acc.AllLifecycle(s)) + { + continue; + } + + switch (state.Status) + { + case BucketStatus.Pending: + pending = s; + break; + case BucketStatus.Invalid: + invalid = s; + break; + case BucketStatus.Unresolved: + unresolved = s; + break; + case BucketStatus.Partial: + partial = s; + break; + } + } + + if (pending >= 0) + { + var state = states[pending]!; + return new BucketValue(null, BucketStatus.Pending, Provenance.None, ValueIssue.AnalysisPending, state.IssueDetail, PathVia(pending, state)); + } + + if (invalid >= 0) + { + var state = states[invalid]!; + var issue = state.Issue == ValueIssue.None ? ValueIssue.InvalidDefinition : state.Issue; + return new BucketValue(null, BucketStatus.Invalid, Provenance.Derived, issue, state.IssueDetail, PathVia(invalid, state)); + } + + if (acc.JointDays == 0) + { + if (Enumerable.Range(0, _inputs.Length).All(s => acc.OwnDays[s] == 0)) + { + return BucketValue.Missing(); + } + + var absent = FirstIncomplete(acc); + return new BucketValue(null, BucketStatus.Missing, Provenance.None, ValueIssue.MissingSource, null, PathVia(absent, states[absent])); + } + + if (unresolved >= 0) + { + var state = states[unresolved]!; + return new BucketValue(null, BucketStatus.Unresolved, acc.JointProvenance, ValueIssue.CoarseResolution, state.IssueDetail, PathVia(unresolved, state)); + } + + var split = acc.FirstSplit(); + if (split >= 0) + { + // The cut is this source's own interval, whatever state it reports about its dependencies. + return new BucketValue(null, BucketStatus.Unresolved, acc.JointProvenance, ValueIssue.CoarseResolution, null, PathVia(split, null)); + } + + var index = _index; + var value = _formula.Evaluate(id => amounts[index[id]]); + if (!double.IsFinite(value)) + { + return new BucketValue(null, BucketStatus.Invalid, acc.JointProvenance, ValueIssue.NonFinite); + } + + if (acc.JointDays < acc.Days) + { + var incomplete = FirstIncomplete(acc); + return new BucketValue(value, BucketStatus.Partial, acc.JointProvenance, ValueIssue.PartialCoverage, null, PathVia(incomplete, states[incomplete])); + } + + if (partial >= 0) + { + var state = states[partial]!; + return new BucketValue(value, BucketStatus.Partial, acc.JointProvenance, OrPartial(state.Issue), state.IssueDetail, PathVia(partial, state)); + } + + return BucketValue.Available(value, acc.JointProvenance); + } + + /// + /// The path from this meter through source to the cause. A path the source reported + /// already starts at the source (a nested evaluation, a loop from ); a + /// physical source's state has none. + /// + private IReadOnlyList PathVia(int s, BucketValue? state) + { + var sourceId = _inputs[s].MeterId; + return state?.DependencyPath is { Count: > 0 } path + ? path[0] == sourceId ? [_meterId, .. path] : [_meterId, sourceId, .. path] + : [_meterId, sourceId]; + } + + private int FirstIncomplete(Accumulator acc) + { + for (var s = 0; s < _inputs.Length; s++) + { + if (acc.OwnDays[s] < acc.Days) + { + return s; + } + } + + return 0; + } + } + + /// How a source stands on the day being added. + private enum DayState + { + /// Not covered and inside its lifetime: unknown, so the day is not jointly covered. + Unknown, + + /// Covered by the source's runs. + Covered, + + /// Outside the source's lifetime: a known zero. + Lifecycle, + } + + /// What an amount on a coarse source's day belongs to: a local month, or a stretch of covered days. + private enum UnitKind + { + None, + Month, + Stretch, + } + + /// The unit a coarse source's covered day belongs to; its amounts are exact only as a whole. + private readonly record struct Unit(UnitKind Kind, int Id); + + /// Running figures over a set of days: per source its own coverage, and the joint coverage of all. + private sealed class Accumulator(int sources) + { + private readonly Unit[] _unit = new Unit[sources]; + private readonly DateOnly?[] _lastCovered = new DateOnly?[sources]; + private readonly bool[] _unitJoint = new bool[sources]; + private readonly bool[] _unitCut = new bool[sources]; + private readonly bool[] _split = new bool[sources]; + + public int Days { get; private set; } + + public int JointDays { get; private set; } + + public double[] JointSums { get; } = new double[sources]; + + public Provenance JointProvenance { get; private set; } = Provenance.Derived; + + public ResolutionClass? Resolution { get; private set; } + + public DateOnly? FirstJointDay { get; private set; } + + public DateOnly? LastJointDay { get; private set; } + + public int[] OwnDays { get; } = new int[sources]; + + public double[] OwnSums { get; } = new double[sources]; + + public Provenance[] OwnProvenance { get; } = new Provenance[sources]; + + public int[] LifecycleDays { get; } = new int[sources]; + + /// True when every day is outside source 's lifetime: its reported state does not apply. + public bool AllLifecycle(int s) => Days > 0 && LifecycleDays[s] == Days; + + /// The first source whose interval the joint coverage cut, or -1. + public int FirstSplit() => Array.IndexOf(_split, true); + + public void Add( + DateOnly day, DayState[] states, SourceDay[] entries, double[] values, bool joint, Provenance provenance, ResolutionClass resolution) + { + Days++; + for (var s = 0; s < states.Length; s++) + { + switch (states[s]) + { + case DayState.Covered: + OwnDays[s]++; + OwnSums[s] += values[s]; + OwnProvenance[s] |= entries[s].Provenance; + Track(s, day, entries[s], joint); + break; + case DayState.Lifecycle: + OwnDays[s]++; + LifecycleDays[s]++; + break; + } + } + + if (!joint) + { + return; + } + + JointDays++; + for (var s = 0; s < values.Length; s++) + { + JointSums[s] += values[s]; + } + + JointProvenance |= provenance; + Resolution = Resolution is { } current ? (ResolutionClass)Math.Max((int)current, (int)resolution) : resolution; + FirstJointDay ??= day; + LastJointDay = day; + } + + /// + /// Follows a coarse source's covered days unit by unit and marks the source split when one unit has both + /// jointly covered days and days the joint coverage dropped. + /// + private void Track(int s, DateOnly day, SourceDay entry, bool joint) + { + var previous = _lastCovered[s]; + _lastCovered[s] = day; + if (entry.Resolution <= ResolutionClass.Day) + { + _unit[s] = default; + return; + } + + var unit = entry.Resolution < ResolutionClass.Coarse && entry.DividedAtMonths + ? new Unit(UnitKind.Month, (day.Year * 12) + day.Month) + : _unit[s].Kind == UnitKind.Stretch && previous == day.AddDays(-1) + ? _unit[s] + : new Unit(UnitKind.Stretch, day.DayNumber); + if (unit != _unit[s]) + { + _unit[s] = unit; + _unitJoint[s] = false; + _unitCut[s] = false; + } + + if (joint) + { + _unitJoint[s] = true; + } + else + { + _unitCut[s] = true; + } + + _split[s] |= _unitJoint[s] && _unitCut[s]; + } + } +} diff --git a/src/Core/Analysis/Virtual/VirtualValidator.cs b/src/Core/Analysis/Virtual/VirtualValidator.cs new file mode 100644 index 0000000..e93ade0 --- /dev/null +++ b/src/Core/Analysis/Virtual/VirtualValidator.cs @@ -0,0 +1,539 @@ +using MeterVault.Core.Analysis.Quantities; + +namespace MeterVault.Core.Analysis.Virtual; + +/// A reason a virtual definition cannot be saved or evaluated as it stands. The UI localizes the kind. +public enum VirtualProblemKind +{ + /// The expression does not parse; says where. + Syntax, + + /// The expression refers to no meter at all (5): a constant is not a meter. + NoReferences, + + /// A referenced id is not a meter. + UnknownMeter, + + /// The meter refers to itself. + SelfReference, + + /// The meter is on, or depends on, a loop of virtual meters; the ids are the path. + DependencyCycle, + + /// A referenced virtual meter has a definition that does not parse. + SourceInvalid, + + /// A referenced virtual meter has no calculation yet (an unconverted legacy meter). + SourceNotConfigured, + + /// A +/- between operands of different units; the values are the two units. + UnitMismatch, + + /// A +/- between different kinds, while the result kind is not net; the values are the two kinds. + KindMismatch, + + /// A meter multiplied by or divided by a meter, while the result kind is not indicator. + ProductNeedsIndicator, + + /// An indicator built from a product or quotient has no declared result unit. + IndicatorNeedsUnit, + + /// + /// No result kind is declared and none follows from the sources: they mix kinds, or share one that is never + /// inferred (runtime, export, net, indicator). The values are the sources' kinds; a kind must be chosen. + /// + ResultKindRequired, + + /// + /// The declared result kind is not one a virtual meter can have (A-08): only consumption, generation, net and + /// indicator are. + /// + ResultKindUnsupported, + + /// The declared result kind contradicts the sources (a sum of generation declared as consumption). + ResultKindMismatch, + + /// The declared result unit contradicts the sources' unit; only an indicator may name its own unit. + ResultUnitMismatch, + + /// The source-costs rule on a formula that is not a pure sum. + CostRuleNeedsPureSum, + + /// The own-quantity rule on a formula that is not linear. + CostRuleNeedsLinear, + + /// A cost rule on an indicator, or on a formula over one; indicators are never costed. + CostRuleNotForIndicator, + + /// + /// A source is an indicator but the result is not. An indicator is never added up, totalled or costed (D-26), so + /// anything built from one is an indicator too. The ids are the indicator sources. + /// + IndicatorSourceNeedsIndicator, +} + +/// +/// One validation finding. names the meters involved — the operands of a mismatch, the +/// path of a loop — and carries data such as the two units; neither is prose. +/// +public sealed record VirtualProblem( + VirtualProblemKind Kind, + IReadOnlyList MeterIds, + IReadOnlyList Values, + FormulaError? SyntaxError = null) +{ + internal static VirtualProblem Of(VirtualProblemKind kind, IReadOnlyList? meterIds = null, params string[] values) => + new(kind, meterIds ?? [], values); +} + +/// +/// The outcome of validating : the problems, and the kind, unit and cost rule the definition +/// effectively has once the undeclared ones are inferred. is in canonical spelling +/// (). A definition is valid only without problems. +/// +public sealed record VirtualValidation( + VirtualDefinition Definition, + Formula? Formula, + IReadOnlyList Problems, + QuantityKind? Kind, + string? Unit, + VirtualCostRule CostRule) +{ + public bool IsValid => Problems.Count == 0; + + /// + /// A declared cost rule the whole calculation does not support, although the formula itself does (A-15): the + /// source-costs rule on a sum over a nested calculation that is not a plain sum + /// (, naming that source). It does not make the definition + /// invalid — its quantity is still evaluated — but the effective is none, so the meter + /// is not costed; a save should refuse the definition until the rule is changed (). + /// + public VirtualProblem? CostRuleProblem { get; init; } + + /// Valid, and without a : what an editor may store. + public bool IsSavable => IsValid && CostRuleProblem is null; + + /// + /// What a save stores (A-08): the definition with its inferred kind, canonical unit and cost rule written out, + /// so no reader has to infer them. Inferring needs the whole catalog, validated in dependency order, and a reader + /// that skipped it would take Summe Solar for consumption. Null while the definition is invalid. + /// + public VirtualDefinition? EffectiveDefinition => + IsValid ? Definition with { ResultKind = Kind, ResultUnit = Unit, CostRule = CostRule } : null; +} + +/// +/// Checks a virtual definition against the meters it refers to (D-26) — on save, and again on every read, +/// because a source's mode, unit or definition can change after the virtual meter was saved. +/// +/// +/// +/// Beyond syntax and references, the checks are about meaning. + and - only combine like with like: +/// kWh with kWh, and consumption with consumption — unless the result is declared , +/// which is the explicit statement that kinds are being mixed (import minus export). Units must match even then; +/// no conversion is ever invented. Multiplying or dividing two meters produces something that is no longer the +/// sources' quantity, so it must be declared an with its own unit, and an +/// indicator is never totalled or costed. Constants are dimensionless: 0.5 * m1 is still kWh (prices inside +/// formulas are deliberately unsupported, D-58). +/// +/// +/// A formula that divides by a constant-only term (1 / m1) is treated like a quotient of meters. Adding a +/// constant (m1 + 5) is allowed, but it makes the formula non-linear, so the series is non-additive and the +/// quantity cannot be priced. +/// +/// +/// A result is consumption, generation, net or indicator, nothing else (A-08). Only a sum of generation or of +/// consumption infers its kind; runtime or export sources need an explicit net or indicator. An indicator source +/// makes the result an indicator, which is never added up or costed. Units compare in canonical spelling (A-09: +/// "kwh" is "kWh", "m3" is "m³"), and the effective unit is canonical, so what is stored is too. +/// +/// +public static class VirtualValidator +{ + /// Validates as the definition of meter . + public static VirtualValidation Validate(VirtualDefinition definition, int meterId, MeterCatalog catalog) + { + ArgumentNullException.ThrowIfNull(definition); + ArgumentNullException.ThrowIfNull(catalog); + + var problems = new List(); + if (!definition.Parsed.Success) + { + problems.Add(new VirtualProblem(VirtualProblemKind.Syntax, [], [], definition.Parsed.Error)); + return new VirtualValidation( + definition, null, problems, definition.ResultKind, CanonicalUnit(definition.ResultUnit), definition.CostRule ?? VirtualCostRule.None); + } + + var formula = definition.Parsed.Formula; + if (formula.MeterIds.Count == 0) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.NoReferences)); + } + + CheckReferences(formula, meterId, catalog, problems); + + var declaredKind = definition.ResultKind; + if (declaredKind is { } unsupported && !DeclaredVirtualResult.AllowedKinds.Contains(unsupported)) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.ResultKindUnsupported, null, VirtualDefinitionJson.KindToken(unsupported))); + declaredKind = null; + } + + var mixingAllowed = declaredKind is null or QuantityKind.Net or QuantityKind.Indicator; + var shape = OperandAnalysis.Run(formula, meterId, catalog, mixingAllowed, problems); + + // A rejected declaration is already reported; "a kind is required" would only repeat it. + var kind = ResolveKind(declaredKind, shape, formula, catalog, problems, reportRequired: declaredKind == definition.ResultKind); + var unit = ResolveUnit(definition.ResultUnit, kind, shape, problems); + + IReadOnlyList indicators = [.. formula.MeterIds.Where(id => id != meterId && catalog.Find(id)?.Kind == QuantityKind.Indicator)]; + CheckIndicatorSources(indicators, kind, problems); + + var nestedNonSum = formula.IsPureSum ? NestedNonSum(formula, meterId, catalog) : null; + var costRule = definition.CostRule + ?? (indicators.Count > 0 || nestedNonSum is not null ? VirtualCostRule.None : DefaultCostRule(formula, kind)); + CheckCostRule(costRule, formula, kind, indicators.Count > 0, problems); + + // A sum over a nested difference is a pure sum at its own level only: its sources' metered costs would include + // the subtrahend's (A-15). The quantity stays valid; the cost rule is refused and taken as none. + VirtualProblem? costRuleProblem = null; + if (costRule == VirtualCostRule.SourceCosts && nestedNonSum is { } nested) + { + costRuleProblem = VirtualProblem.Of(VirtualProblemKind.CostRuleNeedsPureSum, [nested], VirtualDefinitionJson.CostRuleToken(costRule)); + costRule = VirtualCostRule.None; + } + + return new VirtualValidation(definition, formula, problems, kind, unit, costRule) { CostRuleProblem = costRuleProblem }; + } + + /// + /// The first nested virtual source — directly or through nested sums — whose formula is not a pure sum, or null when + /// the sum is a pure sum all the way down (D-23, D-39). Sources without a readable definition are left to the + /// reference checks. + /// + public static int? NestedNonSum(Formula formula, int meterId, MeterCatalog catalog) + { + ArgumentNullException.ThrowIfNull(formula); + ArgumentNullException.ThrowIfNull(catalog); + + var visited = new HashSet { meterId }; + var pending = new Stack(formula.MeterIds.Reverse()); + while (pending.Count > 0) + { + var id = pending.Pop(); + if (!visited.Add(id) || catalog.Find(id) is not { IsVirtual: true, Definition: { } definition } || !definition.Parsed.Success) + { + continue; + } + + var nested = definition.Parsed.Formula; + if (!nested.IsPureSum) + { + return id; + } + + foreach (var source in nested.MeterIds.Reverse()) + { + pending.Push(source); + } + } + + return null; + } + + /// + /// The result kind a formula over sources of these kinds gets when none is declared: a sum of generation meters is + /// generation, of consumption meters consumption. Anything else is null and requires an explicit choice: mixed + /// kinds, a common kind no virtual result may have (runtime, export; A-08), or one whose meaning a sum does not + /// keep (net, indicator). + /// + public static QuantityKind? DefaultKind(IEnumerable sourceKinds) + { + ArgumentNullException.ThrowIfNull(sourceKinds); + + var kinds = sourceKinds.Distinct().ToList(); + return kinds is [var only] && only is QuantityKind.Consumption or QuantityKind.Generation ? only : null; + } + + /// + /// The cost rule a definition gets when none is declared (D-39): the sources' own costs for a pure sum — which + /// cannot double-price anything — and none otherwise, so enabling a virtual meter never changes a bill by itself. + /// A sum of generation is none as well (A-15): generation is never billed (D-34), so its sources have no metered + /// cost to add. also takes none for a sum over a nested calculation that is not a pure sum. + /// + public static VirtualCostRule DefaultCostRule(Formula formula, QuantityKind? resultKind = null) + { + ArgumentNullException.ThrowIfNull(formula); + + return formula.IsPureSum && resultKind is not (QuantityKind.Indicator or QuantityKind.Generation) + ? VirtualCostRule.SourceCosts + : VirtualCostRule.None; + } + + private static void CheckReferences(Formula formula, int meterId, MeterCatalog catalog, List problems) + { + foreach (var id in formula.MeterIds) + { + if (id == meterId) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.SelfReference, [id])); + } + else if (!catalog.TryGet(id, out var source)) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.UnknownMeter, [id])); + } + else if (source.IsVirtual && source.Definition is null) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.SourceNotConfigured, [id])); + } + else if (source.IsVirtual && !source.Definition!.Parsed.Success) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.SourceInvalid, [id])); + } + } + + // The self-reference is reported on its own; the loop check looks for loops through other meters. + var graph = DependencyGraph.FromCatalog(catalog, meterId, [.. formula.MeterIds.Where(id => id != meterId)]); + if (graph.CycleFor(meterId) is { } cycle) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.DependencyCycle, cycle)); + } + } + + private static QuantityKind? ResolveKind( + QuantityKind? declared, Operand shape, Formula formula, MeterCatalog catalog, List problems, bool reportRequired) + { + if (declared is { } kind) + { + if (kind is not (QuantityKind.Net or QuantityKind.Indicator) + && shape is { Shape: OperandShape.Quantity, Kind: { } actual } && actual != kind) + { + problems.Add(VirtualProblem.Of( + VirtualProblemKind.ResultKindMismatch, [shape.MeterId], VirtualDefinitionJson.KindToken(kind), VirtualDefinitionJson.KindToken(actual))); + } + + return kind; + } + + // A product or quotient is reported as needing an indicator (ResolveUnit); mixed kinds need a declaration. + switch (shape.Shape) + { + case OperandShape.Quantity when shape.Kind is { } inferred && DefaultKind([inferred]) is { } kindOfSources: + return kindOfSources; + case OperandShape.Quantity when reportRequired: + var sources = formula.MeterIds.Where(catalog.Contains).ToList(); + problems.Add(new VirtualProblem( + VirtualProblemKind.ResultKindRequired, + sources, + [.. sources.Select(id => VirtualDefinitionJson.KindToken(catalog.Find(id)!.Kind)).Distinct()])); + return null; + default: + return null; + } + } + + private static string? ResolveUnit(string? declaredUnit, QuantityKind? kind, Operand shape, List problems) + { + var declared = string.IsNullOrWhiteSpace(declaredUnit) ? null : declaredUnit.Trim(); + if (shape.Shape == OperandShape.Composite) + { + if (kind != QuantityKind.Indicator) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.ProductNeedsIndicator, shape.ProductMeterIds)); + } + else if (declared is null) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.IndicatorNeedsUnit, shape.ProductMeterIds)); + } + + return CanonicalUnit(declared); + } + + if (shape.Shape == OperandShape.Quantity && declared is not null && kind != QuantityKind.Indicator + && !Units.AreSame(declared, shape.Unit)) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.ResultUnitMismatch, [shape.MeterId], declared, shape.Unit ?? string.Empty)); + } + + return CanonicalUnit(declared ?? (shape.Shape == OperandShape.Quantity ? shape.Unit : null)); + } + + /// The unit as stored and compared (A-09); null stays null. + private static string? CanonicalUnit(string? unit) => unit is null ? null : Units.Normalize(unit); + + private static void CheckIndicatorSources(IReadOnlyList indicators, QuantityKind? kind, List problems) + { + // An undeclared kind over indicators already needs a declaration, and a declared consumption or generation is + // already a kind mismatch. What is left is a net (or an operand mismatch) that would add an indicator up. + if (indicators.Count == 0 || kind is null or QuantityKind.Indicator + || problems.Exists(p => p.Kind is VirtualProblemKind.ResultKindMismatch or VirtualProblemKind.KindMismatch)) + { + return; + } + + problems.Add(VirtualProblem.Of(VirtualProblemKind.IndicatorSourceNeedsIndicator, indicators)); + } + + private static void CheckCostRule(VirtualCostRule rule, Formula formula, QuantityKind? kind, bool overIndicator, List problems) + { + if (rule == VirtualCostRule.None) + { + return; + } + + if (kind == QuantityKind.Indicator || overIndicator) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.CostRuleNotForIndicator, null, VirtualDefinitionJson.CostRuleToken(rule))); + } + else if (rule == VirtualCostRule.SourceCosts && !formula.IsPureSum) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.CostRuleNeedsPureSum, null, VirtualDefinitionJson.CostRuleToken(rule))); + } + else if (rule == VirtualCostRule.OwnQuantity && !formula.IsLinear) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.CostRuleNeedsLinear, null, VirtualDefinitionJson.CostRuleToken(rule))); + } + } + + private enum OperandShape + { + /// A constant-only term: dimensionless. + Scalar, + + /// A quantity of one unit; null once kinds were (allowedly) mixed. + Quantity, + + /// A product or quotient involving meters: its unit is whatever the definition declares. + Composite, + + /// Unknowable — an unknown meter, a self reference, or a mismatch already reported. Silences follow-on errors. + Unknown, + } + + /// What a subterm is, and a representative meter to name in a finding. + private readonly record struct Operand(OperandShape Shape, QuantityKind? Kind, string? Unit, int MeterId, IReadOnlyList ProductMeterIds) + { + public static readonly Operand Scalar = new(OperandShape.Scalar, null, null, 0, []); + + public static Operand Unknown(int meterId) => new(OperandShape.Unknown, null, null, meterId, []); + } + + /// Walks the postfix program once, giving every subterm its and recording mismatches. + private static class OperandAnalysis + { + public static Operand Run(Formula formula, int meterId, MeterCatalog catalog, bool mixingAllowed, List problems) + { + var reported = new HashSet<(VirtualProblemKind, int, int)>(); + var stack = new Stack(); + foreach (var instruction in formula.Program) + { + switch (instruction.Code) + { + case FormulaOpCode.Number: + stack.Push(Operand.Scalar); + break; + case FormulaOpCode.Meter: + stack.Push(Reference(instruction.MeterId, meterId, catalog)); + break; + case FormulaOpCode.Negate: + break; + default: + var right = stack.Pop(); + var left = stack.Pop(); + stack.Push(instruction.Code is FormulaOpCode.Add or FormulaOpCode.Subtract + ? Sum(left, right, mixingAllowed, problems, reported) + : Product(instruction.Code, left, right)); + break; + } + } + + return stack.Pop(); + } + + private static Operand Reference(int id, int meterId, MeterCatalog catalog) => + id != meterId && catalog.TryGet(id, out var source) + ? new Operand(OperandShape.Quantity, source.Kind, Units.Normalize(source.Unit), id, []) + : Operand.Unknown(id); + + private static Operand Sum(Operand left, Operand right, bool mixingAllowed, List problems, HashSet<(VirtualProblemKind, int, int)> reported) + { + if (left.Shape == OperandShape.Unknown || right.Shape == OperandShape.Unknown) + { + return Operand.Unknown(left.Shape == OperandShape.Unknown ? left.MeterId : right.MeterId); + } + + if (left.Shape == OperandShape.Composite || right.Shape == OperandShape.Composite) + { + return Composite(left, right); + } + + if (left.Shape == OperandShape.Scalar) + { + return right; + } + + if (right.Shape == OperandShape.Scalar) + { + return left; + } + + if (!Units.AreSame(left.Unit, right.Unit)) + { + if (reported.Add((VirtualProblemKind.UnitMismatch, left.MeterId, right.MeterId))) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.UnitMismatch, [left.MeterId, right.MeterId], left.Unit ?? string.Empty, right.Unit ?? string.Empty)); + } + + return Operand.Unknown(left.MeterId); + } + + if (left.Kind == right.Kind) + { + return left; + } + + if (mixingAllowed) + { + return left with { Kind = null }; + } + + if (reported.Add((VirtualProblemKind.KindMismatch, left.MeterId, right.MeterId))) + { + problems.Add(VirtualProblem.Of(VirtualProblemKind.KindMismatch, [left.MeterId, right.MeterId], KindText(left.Kind), KindText(right.Kind))); + } + + return Operand.Unknown(left.MeterId); + } + + private static Operand Product(FormulaOpCode code, Operand left, Operand right) + { + if (left.Shape == OperandShape.Unknown || right.Shape == OperandShape.Unknown) + { + return Operand.Unknown(left.Shape == OperandShape.Unknown ? left.MeterId : right.MeterId); + } + + if (right.Shape == OperandShape.Scalar) + { + return left; + } + + if (left.Shape == OperandShape.Scalar && code == FormulaOpCode.Multiply) + { + return right; + } + + return Composite(left, right); + } + + private static Operand Composite(Operand left, Operand right) + { + var ids = left.ProductMeterIds.Concat(right.ProductMeterIds) + .Concat(left.Shape == OperandShape.Quantity ? [left.MeterId] : []) + .Concat(right.Shape == OperandShape.Quantity ? [right.MeterId] : []) + .Distinct().Order().ToList(); + return new Operand(OperandShape.Composite, null, null, ids.FirstOrDefault(), ids); + } + + private static string KindText(QuantityKind? kind) => kind is { } k ? VirtualDefinitionJson.KindToken(k) : "mixed"; + } +} diff --git a/src/Core/Domain/Consumption.cs b/src/Core/Domain/Consumption.cs index 5959bab..96db7f0 100644 --- a/src/Core/Domain/Consumption.cs +++ b/src/Core/Domain/Consumption.cs @@ -1,3 +1,5 @@ +using MeterVault.Core.Analysis; + namespace MeterVault.Core.Domain; /// @@ -5,6 +7,14 @@ namespace MeterVault.Core.Domain; /// (SDD §5.3 consumption). Backed by a TimescaleDB hypertable. /// The marks the end of the interval this delta covers. /// +/// +/// A stored row keeps only its stamp, and a stamp cannot say how much time a row describes: a monthly +/// sheet row, a divided share and a five-minute sample all sit on one instant. So the engine also hands +/// out the interval each row accrued over (D-10) — the one moment that is still known, because only the +/// normalizer sees two readings at once. The rollup and coverage writers read it from here within the +/// same recompute. These properties are derived and not persisted: the consumption +/// schema does not change, and a row read back from the database has them unset. +/// public sealed class Consumption { public DateTimeOffset Time { get; set; } @@ -20,4 +30,62 @@ public sealed class Consumption /// Provenance batch when derived from an import (revert support). public int? ImportBatchId { get; set; } + + /// + /// Where the time this amount accrued over begins (D-10): the previous reading, sample or dipstick, the + /// start of the labelled month, a divided share's own segment start, or the meter's install date for a + /// first register reading. Equal to when the start is unknown + /// (). Derived, not persisted; null on rows read from the database and on + /// rows no normalizer produced. + /// + public DateTimeOffset? IntervalStart { get; set; } + + /// + /// Where that time ends: the instant the closing reading describes — its effective time, which for a + /// month label is the end of the month, and which is not always (a label is stamped + /// inside its month, a midnight reading one second before it, D-11). Derived, not persisted. + /// + public DateTimeOffset? IntervalEnd { get; set; } + + /// + /// The start of the whole interval between the two readings this row came from. For a divided share it is + /// where the undivided interval began, before GapAttribution cut it at month starts; for every other + /// row it equals . Coverage classifies a share by this source interval, and tells + /// the shares of one interval from those of the next by it (A-03): shares of two intervals can meet at a + /// month start exactly as the shares of one do. Derived, not persisted. + /// + public DateTimeOffset? SourceStart { get; set; } + + /// + /// The end of that whole interval: except on a divided share, where it is the + /// closing reading's instant. Derived, not persisted. + /// + public DateTimeOffset? SourceEnd { get; set; } + + /// + /// True when the row's amount is exact for every local month its interval touches, although that + /// interval crosses a month boundary (A-02). Mostly this is one share of an interval the engine divided at + /// local month boundaries (GapAttribution): its interval is the share's own segment, and the amount + /// is an estimate by elapsed time — only the total across the shares was measured. It is also a register + /// that did not move across month boundaries: its zero is exactly zero in every month it spans, so it is + /// kept as one row over the whole interval and its quality is not lowered. Either way a month bucket can + /// trust it. Derived, not persisted. + /// + public bool Divided { get; set; } + + /// + /// True for a first reading booked against the baseline when nothing says since when (no month label, + /// no install date): the amount is real, but its interval is unknown and recorded as zero-length at the + /// reading (D-14). It is never coverage (A-01): the rollup day it is booked in carries a baseline-delta + /// flag instead, and bucket status and matched coverage take that flag and this row's stamp as inputs. + /// Derived, not persisted. + /// + public bool OpeningBalance { get; set; } + + /// + /// Why this row's interval is a known hole rather than covered time (D-13), e.g. a register that fell + /// without a recorded swap. The amount still stands for what it is; coverage reports the interval as a + /// gap. Derived, not persisted. + /// + public CoverageGapReason Gap { get; set; } } diff --git a/src/Core/Normalization/Expressions/ExpressionEvaluator.cs b/src/Core/Normalization/Expressions/ExpressionEvaluator.cs deleted file mode 100644 index 3bccc45..0000000 --- a/src/Core/Normalization/Expressions/ExpressionEvaluator.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System.Globalization; - -namespace MeterVault.Core.Normalization.Expressions; - -/// -/// A tiny, safe arithmetic evaluator for virtual-meter expressions (SDD §7.4). Supports -/// + - * /, parentheses, unary minus, numeric literals, and identifiers resolved against -/// a supplied variable map (e.g. m1 - m2 where m1 is meter 1's amount in a bucket). -/// No I/O, no reflection, no arbitrary code — only arithmetic over whitelisted tokens. -/// Parse once with , then evaluate per time bucket. -/// -public sealed class ExpressionEvaluator -{ - private readonly Func, double> _eval; - - private ExpressionEvaluator(Func, double> eval) => _eval = eval; - - public static ExpressionEvaluator Compile(string expression) - { - ArgumentException.ThrowIfNullOrWhiteSpace(expression); - var parser = new Parser(expression); - var node = parser.ParseExpression(); - parser.ExpectEnd(); - return new ExpressionEvaluator(node); - } - - public double Evaluate(IReadOnlyDictionary variables) => _eval(variables); - - // Recursive-descent parser that builds a closure over the variable map. - private sealed class Parser(string text) - { - private int _pos; - - public Func, double> ParseExpression() => ParseAdditive(); - - public void ExpectEnd() - { - SkipWhitespace(); - if (_pos != text.Length) - { - throw new FormatException($"Unexpected token at position {_pos} in expression '{text}'."); - } - } - - private Func, double> ParseAdditive() - { - var left = ParseMultiplicative(); - while (true) - { - SkipWhitespace(); - if (Match('+')) - { - var right = ParseMultiplicative(); - var l = left; - left = vars => l(vars) + right(vars); - } - else if (Match('-')) - { - var right = ParseMultiplicative(); - var l = left; - left = vars => l(vars) - right(vars); - } - else - { - return left; - } - } - } - - private Func, double> ParseMultiplicative() - { - var left = ParseUnary(); - while (true) - { - SkipWhitespace(); - if (Match('*')) - { - var right = ParseUnary(); - var l = left; - left = vars => l(vars) * right(vars); - } - else if (Match('/')) - { - var right = ParseUnary(); - var l = left; - left = vars => l(vars) / right(vars); - } - else - { - return left; - } - } - } - - private Func, double> ParseUnary() - { - SkipWhitespace(); - if (Match('-')) - { - var operand = ParseUnary(); - return vars => -operand(vars); - } - - if (Match('+')) - { - return ParseUnary(); - } - - return ParsePrimary(); - } - - private Func, double> ParsePrimary() - { - SkipWhitespace(); - if (Match('(')) - { - var inner = ParseAdditive(); - SkipWhitespace(); - if (!Match(')')) - { - throw new FormatException($"Expected ')' at position {_pos} in expression '{text}'."); - } - - return inner; - } - - if (_pos < text.Length && (char.IsDigit(text[_pos]) || text[_pos] == '.')) - { - var start = _pos; - while (_pos < text.Length && (char.IsDigit(text[_pos]) || text[_pos] == '.')) - { - _pos++; - } - - var literal = double.Parse(text.AsSpan(start, _pos - start), CultureInfo.InvariantCulture); - return _ => literal; - } - - if (_pos < text.Length && (char.IsLetter(text[_pos]) || text[_pos] == '_')) - { - var start = _pos; - while (_pos < text.Length && (char.IsLetterOrDigit(text[_pos]) || text[_pos] == '_')) - { - _pos++; - } - - var name = text[start.._pos]; - return vars => vars.TryGetValue(name, out var value) ? value : 0d; - } - - throw new FormatException($"Unexpected character at position {_pos} in expression '{text}'."); - } - - private bool Match(char c) - { - SkipWhitespace(); - if (_pos < text.Length && text[_pos] == c) - { - _pos++; - return true; - } - - return false; - } - - private void SkipWhitespace() - { - while (_pos < text.Length && char.IsWhiteSpace(text[_pos])) - { - _pos++; - } - } - } -} diff --git a/src/Core/Normalization/GapAttribution.cs b/src/Core/Normalization/GapAttribution.cs index 53de177..7854a5e 100644 --- a/src/Core/Normalization/GapAttribution.cs +++ b/src/Core/Normalization/GapAttribution.cs @@ -118,7 +118,7 @@ public static class GapAttribution if (to <= from) { - return [new GapSegment(closingStamp.ToUniversalTime(), amount)]; + return [new GapSegment(closingStamp.ToUniversalTime(), amount, from.ToUniversalTime(), to.ToUniversalTime())]; } var total = to - from; @@ -148,7 +148,7 @@ public static class GapAttribution // UTC, like every stored instant (SDD §10): the boundaries are computed as local wall-clock // times, and PostgreSQL's timestamptz accepts only zero offsets from Npgsql. - segments.Add(new GapSegment(stamp.ToUniversalTime(), share)); + segments.Add(new GapSegment(stamp.ToUniversalTime(), share, cursor.ToUniversalTime(), segmentEnd.ToUniversalTime())); assigned += share; cursor = segmentEnd; } @@ -156,6 +156,73 @@ public static class GapAttribution return segments; } + /// + /// Where a row that is not a month label is stamped when it closes the interval [start, end] + /// (D-11): at , unless that is exactly a local midnight — then one second + /// earlier, inside the day the interval describes. + /// + /// + /// A reading taken at 00:00 closes the day before it. Stamped at the midnight itself, a daily snapshot + /// would file every day's use under the next day, and the one taken at 00:00 on the 1st would carry the + /// last day of a month into the next month. This mirrors how a divided share is stamped inside its month + /// (the last second, or the midpoint of an interval shorter than that). A zero-length interval — a first + /// reading with an unknown start — describes no time before the midnight, so it stays on it. A month + /// label keeps : its stamp names a month, not the moment of reading. + /// + public static DateTimeOffset CloseStamp(DateTimeOffset start, DateTimeOffset end, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + + return start < end && IsLocalMidnight(end, zone) + ? InsideSegment(start, end, end).ToUniversalTime() + : end.ToUniversalTime(); + } + + /// + /// True when is the start of a local calendar day in + /// — the same instant names, so a day whose clocks + /// skip midnight starts at its first existing moment, and a midnight that happens twice starts the day + /// only the first time. + /// + public static bool IsLocalMidnight(DateTimeOffset instant, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + + var local = TimeZoneInfo.ConvertTime(instant, zone); + + // A day starts within its first hours even where a transition skips midnight; anything later in the + // day is not a start, and this is on the path of every normalized row. + return local.TimeOfDay < TimeSpan.FromHours(12) + && instant == LocalMidnight(DateOnly.FromDateTime(local.DateTime), zone); + } + + /// True when is the local midnight that starts a calendar month. + public static bool IsLocalMonthStart(DateTimeOffset instant, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + + var local = TimeZoneInfo.ConvertTime(instant, zone); + return local.Day == 1 && IsLocalMidnight(instant, zone); + } + + /// + /// True when a local month boundary lies strictly inside (start, end): an interval that does not + /// is wholly inside one local month (its ends may sit on the boundaries), so month buckets resolve it. + /// + public static bool CrossesLocalMonthBoundary(DateTimeOffset start, DateTimeOffset end, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(zone); + + if (end <= start) + { + return false; + } + + var local = TimeZoneInfo.ConvertTime(start, zone); + var nextMonthStart = LocalMidnight(new DateOnly(local.Year, local.Month, 1).AddMonths(1), zone); + return nextMonthStart < end; + } + /// The month a label names — its UTC month, which is what the importer wrote. private static DateTime LabelledMonth(Reading reading) { @@ -212,5 +279,8 @@ public static class GapAttribution } } -/// One month's share of an interval: the instant it is stamped at and the amount attributed. -public sealed record GapSegment(DateTimeOffset Time, double Amount); +/// +/// One month's share of an interval: the instant it is stamped at, the amount attributed, and the part of +/// the interval it covers, [From, To) — the source interval of the row it becomes (D-10). +/// +public sealed record GapSegment(DateTimeOffset Time, double Amount, DateTimeOffset From, DateTimeOffset To); diff --git a/src/Core/Normalization/MeterConfig.cs b/src/Core/Normalization/MeterConfig.cs index c3d169f..f1cdf23 100644 --- a/src/Core/Normalization/MeterConfig.cs +++ b/src/Core/Normalization/MeterConfig.cs @@ -17,6 +17,15 @@ public sealed record MeterConfig /// Register baseline for a newly installed meter (SDD §7.1). Default 0. public double InitialBaseline { get; init; } + /// + /// The local date the meter was installed, when known. A register's first reading is booked against the + /// baseline, and this is what says since when: its interval starts at this date's local midnight (D-10). + /// Without it, a first reading that is not a month label has an unknown start — an opening balance. A + /// direct-delta source ignores it: its first increment covers one reporting step, not the time since + /// installation. + /// + public DateOnly? InstalledAt { get; init; } + /// Tank/runtime configuration for consumable_balance and runtime_counter meters. public TankConfig? Tank { get; init; } diff --git a/src/Core/Normalization/NormalizationEngine.cs b/src/Core/Normalization/NormalizationEngine.cs index 7194264..554723b 100644 --- a/src/Core/Normalization/NormalizationEngine.cs +++ b/src/Core/Normalization/NormalizationEngine.cs @@ -1,3 +1,4 @@ +using MeterVault.Core.Analysis; using MeterVault.Core.Domain; namespace MeterVault.Core.Normalization; @@ -16,7 +17,12 @@ public sealed class NormalizationEngine : INormalizationEngine _byMode = normalizers.ToDictionary(n => n.Mode); } - /// Builds an engine with the built-in strategies for all supported modes. + /// + /// Builds an engine with the built-in strategies for every physical mode. Virtual meters have none: they + /// store nothing and are evaluated on read from their sources' series (D-27, D-29), so there is exactly one + /// evaluator — — and no second copy of a virtual meter's + /// values that could disagree with it. + /// public static NormalizationEngine CreateDefault() => new( [ new Normalizers.CumulativeCounterNormalizer(), @@ -25,7 +31,6 @@ public sealed class NormalizationEngine : INormalizationEngine new Normalizers.ConsumableBalanceNormalizer(), new Normalizers.DirectDeltaNormalizer(), new Normalizers.InstantRateNormalizer(), - new Normalizers.VirtualNormalizer(), ]); public IReadOnlyList Normalize(NormalizationContext context) @@ -46,7 +51,13 @@ public sealed class NormalizationEngine : INormalizationEngine /// exactly that second, or at a local midnight a label is stamped at. Their amounts add up: the /// total is what the readings say, only its placement is shared. /// - private static List Coalesce(IEnumerable rows) + /// + /// The merged row describes both source intervals (D-10): it spans from the earlier start to the + /// later end (its interval and its whole source interval alike), is a divided share only if both + /// were, and keeps an opening balance or a gap if either had one — coverage must never read a merge + /// as better evidence than its parts. + /// + internal static List Coalesce(IEnumerable rows) { var byKey = new Dictionary<(DateTimeOffset Time, ConsumptionKind Kind), Consumption>(); foreach (var row in rows) @@ -56,6 +67,16 @@ public sealed class NormalizationEngine : INormalizationEngine existing.Amount += row.Amount; existing.Quality = ReadingQuality.Estimated; existing.ImportBatchId ??= row.ImportBatchId; + existing.IntervalStart = Earlier(existing.IntervalStart, row.IntervalStart); + existing.IntervalEnd = Later(existing.IntervalEnd, row.IntervalEnd); + existing.SourceStart = Earlier(existing.SourceStart, row.SourceStart); + existing.SourceEnd = Later(existing.SourceEnd, row.SourceEnd); + existing.Divided &= row.Divided; + existing.OpeningBalance |= row.OpeningBalance; + if (existing.Gap == CoverageGapReason.None) + { + existing.Gap = row.Gap; + } } else { @@ -65,4 +86,10 @@ public sealed class NormalizationEngine : INormalizationEngine return [.. byKey.Values.OrderBy(r => r.Time).ThenBy(r => r.Kind)]; } + + private static DateTimeOffset? Earlier(DateTimeOffset? a, DateTimeOffset? b) => + a is null ? b : b is null ? a : a < b ? a : b; + + private static DateTimeOffset? Later(DateTimeOffset? a, DateTimeOffset? b) => + a is null ? b : b is null ? a : a > b ? a : b; } diff --git a/src/Core/Normalization/Normalizers/ConsumableBalanceNormalizer.cs b/src/Core/Normalization/Normalizers/ConsumableBalanceNormalizer.cs index c626286..ddb4ead 100644 --- a/src/Core/Normalization/Normalizers/ConsumableBalanceNormalizer.cs +++ b/src/Core/Normalization/Normalizers/ConsumableBalanceNormalizer.cs @@ -10,6 +10,15 @@ namespace MeterVault.Core.Normalization.Normalizers; /// heating-oil delivery-only rows of 1997–2020 produce nothing until the first 2022 dipstick. /// This reproduces the spreadsheet's Differenz Tank column. /// +/// +/// A row's interval runs from the previous level reading to this one (D-10): the draw is only known +/// between two dipsticks. Deliveries before the first level produce no row, so the time before it is +/// not coverage (D-13). A dipstick taken at exactly a local midnight is stamped inside the day it +/// closes (D-11). The draw is never divided across months, except when there was none: a draw only +/// grows, so a zero over the interval is zero in every month it spans, and that row is marked +/// (A-02). A level that rose beyond the recorded deliveries is clamped to +/// zero, which is a guess, not a measured standstill. +/// public sealed class ConsumableBalanceNormalizer : IMeterNormalizer { public MeterMode Mode => MeterMode.ConsumableBalance; @@ -28,6 +37,7 @@ public sealed class ConsumableBalanceNormalizer : IMeterNormalizer .ToList(); double? lastLevelVolume = null; + DateTimeOffset lastLevelTime = default; double pendingDeliveries = 0; foreach (var e in events) @@ -42,13 +52,20 @@ public sealed class ConsumableBalanceNormalizer : IMeterNormalizer if (lastLevelVolume is null) { lastLevelVolume = currVolume; + lastLevelTime = e.Time; pendingDeliveries = 0; continue; } var consumption = lastLevelVolume.Value + pendingDeliveries - currVolume; var quality = ReadingQuality.Manual; - if (consumption < 0) + var noDraw = Math.Abs(consumption) <= CounterNormalizerBase.StandstillTolerance; + if (noDraw) + { + // Level and deliveries agree to float noise: nothing was drawn. + consumption = 0; + } + else if (consumption < 0) { // Level rose beyond recorded deliveries — treat as no net draw, flag as estimated. consumption = 0; @@ -58,14 +75,20 @@ public sealed class ConsumableBalanceNormalizer : IMeterNormalizer yield return new Consumption { MeterId = context.Meter.MeterId, - Time = e.Time, + Time = GapAttribution.CloseStamp(lastLevelTime, e.Time, context.TimeZone), Amount = consumption, Kind = ConsumptionKind.Consumption, Quality = quality, ImportBatchId = e.ImportBatchId, + IntervalStart = lastLevelTime.ToUniversalTime(), + IntervalEnd = e.Time.ToUniversalTime(), + SourceStart = lastLevelTime.ToUniversalTime(), + SourceEnd = e.Time.ToUniversalTime(), + Divided = noDraw && GapAttribution.CrossesLocalMonthBoundary(lastLevelTime, e.Time, context.TimeZone), }; lastLevelVolume = currVolume; + lastLevelTime = e.Time; pendingDeliveries = 0; } } diff --git a/src/Core/Normalization/Normalizers/CounterNormalizerBase.cs b/src/Core/Normalization/Normalizers/CounterNormalizerBase.cs index b1028f8..c169c80 100644 --- a/src/Core/Normalization/Normalizers/CounterNormalizerBase.cs +++ b/src/Core/Normalization/Normalizers/CounterNormalizerBase.cs @@ -1,3 +1,4 @@ +using MeterVault.Core.Analysis; using MeterVault.Core.Domain; namespace MeterVault.Core.Normalization.Normalizers; @@ -22,11 +23,30 @@ namespace MeterVault.Core.Normalization.Normalizers; /// /// /// +/// /// "Ascending" is the order of : an imported month row describes the /// register at the end of its month, so it comes after live readings taken during that month. +/// +/// +/// Every row carries the interval it accrued over (D-10): from the previous reading's effective time to +/// this one's, a divided share its own segment, and the first reading the interval +/// gives it. Every row also names the whole reading interval it came +/// from (), so coverage can classify a share by the interval it was +/// estimated from. An unexplained decrease and a reset without the old register's final value are known +/// holes, not coverage (D-13): the rows keep their amounts, and their intervals are marked as gaps. A +/// reading at exactly a local midnight is stamped one second earlier, inside the day it closes (D-11). +/// +/// +/// A register that did not move across a month boundary is not fanned out into empty rows, but its one row +/// is marked : a zero is exactly zero in every month it spans, so month +/// buckets can trust it (A-02). It keeps its reading's quality, because nothing about it was estimated. +/// /// public abstract class CounterNormalizerBase : IMeterNormalizer { + /// An advance this small is float noise from subtracting register values, not consumption. + internal const double StandstillTolerance = 1e-9; + public abstract MeterMode Mode { get; } protected abstract ConsumptionKind Kind { get; } @@ -51,6 +71,7 @@ public abstract class CounterNormalizerBase : IMeterNormalizer { var quality = reading.Quality == ReadingQuality.Measured ? ReadingQuality.Measured : reading.Quality; var swap = RegisterBoundary.Find(swaps, previousEffective, effective, timeline.BoundaryTime); + var gap = CoverageGapReason.None; double amount; var plainIncrease = false; @@ -61,6 +82,13 @@ public abstract class CounterNormalizerBase : IMeterNormalizer else if (swap is { EventType: MeterEventType.CounterReset }) { amount = RegisterBoundary.Advance(swap, previous, reading.Value); + + // Without the old register's final value, whatever it advanced before the reset is not + // known: the amount is only the new register's part of the interval. + if (swap.PrevValue is null) + { + gap = CoverageGapReason.ResetWithoutPrevious; + } } else if (reading.Value >= previous) { @@ -72,16 +100,26 @@ public abstract class CounterNormalizerBase : IMeterNormalizer // Unexplained decrease: emit nothing meaningful, rebaseline, and mark quality. amount = 0; quality = ReadingQuality.Estimated; + gap = CoverageGapReason.UnexplainedDecrease; } + var interval = previousEffective is { } previousEnd + ? new SourceInterval(previousEnd, effective, OpeningBalance: false) + : SourceInterval.First(reading, effective, context.Meter, zone, registerSinceInstall: true); + // Only a plain increase is attributed across months (SDD §7.1). A swap or reset amount is an // explicit correction booked at its event; a rejected decrease contributes nothing; the - // first reading has no interval behind it; and fanning a zero out across months just adds - // rows that say nothing. - var stamp = GapAttribution.StampTime(reading, zone); - var segments = plainIncrease && Math.Abs(amount) > 1e-9 && previousEffective is { } from + // first reading is booked at its reading, whatever its interval says about the time behind + // it; and fanning a zero out across months just adds rows that say nothing. + var stamp = interval.Stamp(reading, zone); + var moved = Math.Abs(amount) > StandstillTolerance; + var segments = plainIncrease && moved && previousEffective is { } from ? GapAttribution.Attribute(from, effective, stamp, amount, zone) - : [new GapSegment(stamp.ToUniversalTime(), amount)]; + : [new GapSegment(stamp, amount, interval.Start.ToUniversalTime(), interval.End.ToUniversalTime())]; + var divided = segments.Count > 1; + + // A register that stood still is exactly zero in every month the interval spans (A-02). + var standstill = plainIncrease && !moved && GapAttribution.CrossesLocalMonthBoundary(interval.Start, interval.End, zone); foreach (var segment in segments) { @@ -93,8 +131,15 @@ public abstract class CounterNormalizerBase : IMeterNormalizer Kind = Kind, // A divided interval's total is measured; only its distribution across the months is // inferred. - Quality = segments.Count > 1 ? ReadingQuality.Estimated : quality, + Quality = divided ? ReadingQuality.Estimated : quality, ImportBatchId = reading.ImportBatchId, + IntervalStart = segment.From, + IntervalEnd = segment.To, + SourceStart = interval.Start.ToUniversalTime(), + SourceEnd = interval.End.ToUniversalTime(), + Divided = divided || standstill, + OpeningBalance = interval.OpeningBalance, + Gap = gap, }; } diff --git a/src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs b/src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs index c463603..1cb31ad 100644 --- a/src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs +++ b/src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs @@ -7,6 +7,16 @@ namespace MeterVault.Core.Normalization.Normalizers; /// consumption for its interval, used verbatim. An imported month row ("August 2026" = 20) is that /// month's consumption and is stamped inside it (). /// +/// +/// An increment covers the time since the source's previous report, so a row's interval starts at the +/// previous reading in order; a month row covers the local month it names +/// whatever its neighbours (D-10). The very first increment that is not a month row has no previous +/// report, and nothing records how long the source's reporting step was, so it is an opening balance with +/// an unknown start — never "since the install date", which would let one small increment claim years of +/// coverage (). A report at exactly a local midnight is stamped inside the +/// day it closes (D-11). A zero increment across a month boundary says nothing was used in any of those +/// months, so it is marked (A-02). +/// public sealed class DirectDeltaNormalizer : IMeterNormalizer { public MeterMode Mode => MeterMode.DirectDelta; @@ -15,17 +25,32 @@ public sealed class DirectDeltaNormalizer : IMeterNormalizer { ArgumentNullException.ThrowIfNull(context); - foreach (var reading in context.Readings.OrderBy(x => x.Time)) + var zone = context.TimeZone; + DateTimeOffset? previousEffective = null; + foreach (var (reading, effective) in ReadingTimeline.Build(context.Readings, zone).Readings) { + var interval = previousEffective is { } previousEnd && !GapAttribution.IsMonthLabel(reading) + ? new SourceInterval(previousEnd, effective, OpeningBalance: false) + : SourceInterval.First(reading, effective, context.Meter, zone, registerSinceInstall: false); + yield return new Consumption { MeterId = context.Meter.MeterId, - Time = GapAttribution.StampTime(reading, context.TimeZone).ToUniversalTime(), + Time = interval.Stamp(reading, zone), Amount = reading.Value, Kind = ConsumptionKind.Consumption, Quality = reading.Quality, ImportBatchId = reading.ImportBatchId, + IntervalStart = interval.Start.ToUniversalTime(), + IntervalEnd = interval.End.ToUniversalTime(), + SourceStart = interval.Start.ToUniversalTime(), + SourceEnd = interval.End.ToUniversalTime(), + Divided = Math.Abs(reading.Value) <= CounterNormalizerBase.StandstillTolerance + && GapAttribution.CrossesLocalMonthBoundary(interval.Start, interval.End, zone), + OpeningBalance = interval.OpeningBalance, }; + + previousEffective = effective; } } } diff --git a/src/Core/Normalization/Normalizers/InstantRateNormalizer.cs b/src/Core/Normalization/Normalizers/InstantRateNormalizer.cs index 7fc5d81..bc51571 100644 --- a/src/Core/Normalization/Normalizers/InstantRateNormalizer.cs +++ b/src/Core/Normalization/Normalizers/InstantRateNormalizer.cs @@ -1,3 +1,5 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; using MeterVault.Core.Domain; namespace MeterVault.Core.Normalization.Normalizers; @@ -15,8 +17,27 @@ namespace MeterVault.Core.Normalization.Normalizers; /// Signs are preserved (a bidirectional power sensor may go negative on export). /// /// +/// +/// +/// Each row's interval is the stretch between its two samples (D-10). Samples are instants whatever +/// their flags — a rate cannot be a monthly figure — so a sample at exactly a local midnight is stamped +/// inside the day it closes (D-11), and an interval far longer than the sensor's usual rhythm is reported +/// as a sample gap (D-13). +/// +/// +/// The rhythm is read locally, from the steps around each interval, not from the meter's whole history: a +/// sensor that was polled every ten seconds for a year and hourly since would otherwise have every hourly +/// step flagged, and one that went from hourly to fast would hide real outages. A silence no longer than an +/// hourly interval (, an hour and a minute of poll jitter) is +/// never a gap. A push-on-change source that holds a constant value (a PV sensor at 0 W overnight) is still +/// indistinguishable from an outage by timing alone. +/// +/// public sealed class InstantRateNormalizer : IMeterNormalizer { + /// How many sample steps on each side of an interval its local rhythm is read from. + internal const int RhythmWindow = 10; + public MeterMode Mode => MeterMode.InstantRate; public IEnumerable Normalize(NormalizationContext context) @@ -24,32 +45,95 @@ public sealed class InstantRateNormalizer : IMeterNormalizer ArgumentNullException.ThrowIfNull(context); var readings = context.Readings.OrderBy(r => r.Time).ToList(); + var gapLongerThan = SampleGapThresholds(readings); - Reading? previous = null; - foreach (var reading in readings) + for (var i = 1; i < readings.Count; i++) { - if (previous is not null) + var previous = readings[i - 1]; + var reading = readings[i]; + var elapsed = reading.Time - previous.Time; + if (elapsed <= TimeSpan.Zero) { - var hours = (reading.Time - previous.Time).TotalHours; - if (hours > 0) - { - // Trapezoidal integral of the rate over [previous, reading]; the linear mean of - // the two samples is exact for a rate that varies linearly between them. - var amount = (previous.Value + reading.Value) / 2d * hours; + continue; + } - yield return new Consumption - { - MeterId = context.Meter.MeterId, - Time = reading.Time, - Amount = amount, - Kind = ConsumptionKind.Consumption, - Quality = reading.Quality, - ImportBatchId = reading.ImportBatchId, - }; + // Trapezoidal integral of the rate over [previous, reading]; the linear mean of the two samples + // is exact for a rate that varies linearly between them. + var amount = (previous.Value + reading.Value) / 2d * elapsed.TotalHours; + + // Across a long silence the straight line between two samples is a guess, not a measurement: + // keep the integral so the total stays what the samples say, but mark it estimated and report + // the stretch as a hole (D-13). + var gap = elapsed > gapLongerThan[i - 1]; + + yield return new Consumption + { + MeterId = context.Meter.MeterId, + Time = GapAttribution.CloseStamp(previous.Time, reading.Time, context.TimeZone), + Amount = amount, + Kind = ConsumptionKind.Consumption, + Quality = gap ? ReadingQuality.Estimated : reading.Quality, + ImportBatchId = reading.ImportBatchId, + IntervalStart = previous.Time.ToUniversalTime(), + IntervalEnd = reading.Time.ToUniversalTime(), + SourceStart = previous.Time.ToUniversalTime(), + SourceEnd = reading.Time.ToUniversalTime(), + Gap = gap ? CoverageGapReason.SampleGap : CoverageGapReason.None, + }; + } + } + + /// + /// How long each silence between two samples may last before it is a gap: ten times the median of the + /// sample steps around it (up to on each side, itself included), and never + /// less than , so a sensor that reports every few seconds is + /// not declared broken by a short hiccup or an hourly poll a few seconds late (D-13). The median — not + /// the mean — so a gap does not raise its own threshold unless the steps around it are just as long, + /// which is a new rhythm rather than a hole. + /// + /// The samples in time order. + /// + /// One threshold per step: entry i is for the interval ending at sample i + 1. + /// where no positive step is near enough to give a rhythm. + /// + internal static TimeSpan[] SampleGapThresholds(IReadOnlyList ordered) + { + ArgumentNullException.ThrowIfNull(ordered); + + var steps = new long[Math.Max(0, ordered.Count - 1)]; + for (var i = 0; i < steps.Length; i++) + { + steps[i] = (ordered[i + 1].Time - ordered[i].Time).Ticks; + } + + var thresholds = new TimeSpan[steps.Length]; + var window = new List((2 * RhythmWindow) + 1); + for (var i = 0; i < steps.Length; i++) + { + window.Clear(); + for (var j = Math.Max(0, i - RhythmWindow); j <= Math.Min(steps.Length - 1, i + RhythmWindow); j++) + { + if (steps[j] > 0) + { + window.Add(steps[j]); } } - previous = reading; + thresholds[i] = window.Count == 0 ? TimeSpan.MaxValue : TenMedians(window); } + + return thresholds; + } + + private static TimeSpan TenMedians(List steps) + { + steps.Sort(); + var middle = steps.Count / 2; + var median = steps.Count % 2 == 1 + ? steps[middle] + : steps[middle - 1] + ((steps[middle] - steps[middle - 1]) / 2); + + var tenfold = median > TimeSpan.MaxValue.Ticks / 10 ? TimeSpan.MaxValue : TimeSpan.FromTicks(median * 10); + return tenfold > ResolutionClassifier.HourLimit ? tenfold : ResolutionClassifier.HourLimit; } } diff --git a/src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs b/src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs index 4564486..1e5f48c 100644 --- a/src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs +++ b/src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs @@ -1,3 +1,4 @@ +using MeterVault.Core.Analysis; using MeterVault.Core.Domain; namespace MeterVault.Core.Normalization.Normalizers; @@ -16,7 +17,12 @@ namespace MeterVault.Core.Normalization.Normalizers; /// books the old counter's tail plus the new counter's advance. Without one, a decrease still books /// nothing rather than a negative runtime. Readings are walked in order and /// a month row's hours are stamped inside the month it names, like any other register; the hours are -/// not divided across months. +/// not divided across months. Each row's interval runs from the previous reading's effective time (D-10), +/// so a long stretch without readings stays one long, undivided interval — which is what tells coverage +/// that its hours cannot be placed in any single month; a reading at exactly a local midnight is stamped +/// inside the day it closes (D-11). The one exception is a counter that did not move: zero hours across a +/// month boundary are exactly zero in every month, so that row is marked +/// and month buckets can trust it (A-02). /// public sealed class RuntimeCounterNormalizer : IMeterNormalizer { @@ -35,8 +41,10 @@ public sealed class RuntimeCounterNormalizer : IMeterNormalizer foreach (var (reading, effective) in timeline.Readings) { var boundary = RegisterBoundary.Find(boundaries, previousTime, effective, timeline.BoundaryTime); + var gap = CoverageGapReason.None; double amount; + var stoodStill = false; if (boundary is { EventType: MeterEventType.MeterSwap, Amount: { } explicitAmount }) { amount = explicitAmount; @@ -47,19 +55,42 @@ public sealed class RuntimeCounterNormalizer : IMeterNormalizer ? RegisterBoundary.Advance(boundary, previous, reading.Value) : reading.Value - previous; amount = Math.Max(0d, deltaHours) * rate; + stoodStill = boundary is null && Math.Abs(deltaHours) <= CounterNormalizerBase.StandstillTolerance; + + // The same holes as a register's (D-13): hours that went backwards with nothing to explain + // it, or a reset that does not say where the old counter stopped. + if (boundary is null && deltaHours < 0) + { + gap = CoverageGapReason.UnexplainedDecrease; + } + else if (boundary is { EventType: MeterEventType.CounterReset, PrevValue: null }) + { + gap = CoverageGapReason.ResetWithoutPrevious; + } } + var interval = previousTime is { } previousEnd + ? new SourceInterval(previousEnd, effective, OpeningBalance: false) + : SourceInterval.First(reading, effective, context.Meter, context.TimeZone, registerSinceInstall: true); + previous = reading.Value; previousTime = effective; yield return new Consumption { MeterId = context.Meter.MeterId, - Time = GapAttribution.StampTime(reading, context.TimeZone).ToUniversalTime(), + Time = interval.Stamp(reading, context.TimeZone), Amount = amount, Kind = ConsumptionKind.Consumption, Quality = reading.Quality, ImportBatchId = reading.ImportBatchId, + IntervalStart = interval.Start.ToUniversalTime(), + IntervalEnd = interval.End.ToUniversalTime(), + SourceStart = interval.Start.ToUniversalTime(), + SourceEnd = interval.End.ToUniversalTime(), + Divided = stoodStill && GapAttribution.CrossesLocalMonthBoundary(interval.Start, interval.End, context.TimeZone), + OpeningBalance = interval.OpeningBalance, + Gap = gap, }; } } diff --git a/src/Core/Normalization/Normalizers/VirtualNormalizer.cs b/src/Core/Normalization/Normalizers/VirtualNormalizer.cs deleted file mode 100644 index 03fb37b..0000000 --- a/src/Core/Normalization/Normalizers/VirtualNormalizer.cs +++ /dev/null @@ -1,66 +0,0 @@ -using MeterVault.Core.Domain; -using MeterVault.Core.Normalization.Expressions; - -namespace MeterVault.Core.Normalization.Normalizers; - -/// -/// A meter computed from other meters' series via a user-defined expression (SDD §7.4). Each -/// referenced meter is exposed to the expression as m{id} (its amount in the current time -/// bucket). This is how PV self-consumption/savings and the electricity net figures are modelled -/// without hardcoding — e.g. Netz Einsparung = m1 - m2 (Haus − Netz) evaluated per month. -/// -public sealed class VirtualNormalizer : IMeterNormalizer -{ - public MeterMode Mode => MeterMode.Virtual; - - public IEnumerable Normalize(NormalizationContext context) - { - ArgumentNullException.ThrowIfNull(context); - var spec = context.Meter.Virtual - ?? throw new InvalidOperationException( - $"Virtual meter {context.Meter.MeterId} has no VirtualSpec."); - - var evaluator = ExpressionEvaluator.Compile(spec.Expression); - - // Per referenced meter: time → summed amount in that bucket. - var byMeter = new Dictionary>(); - var allTimes = new SortedSet(); - foreach (var meterId in spec.ReferencedMeterIds) - { - var series = context.ReferencedSeries.TryGetValue(meterId, out var s) ? s : []; - var byTime = new Dictionary(); - foreach (var row in series) - { - byTime[row.Time] = byTime.GetValueOrDefault(row.Time) + row.Amount; - allTimes.Add(row.Time); - } - - byMeter[meterId] = byTime; - } - - var variables = new Dictionary(StringComparer.Ordinal); - foreach (var time in allTimes) - { - variables.Clear(); - foreach (var meterId in spec.ReferencedMeterIds) - { - variables[$"m{meterId}"] = byMeter[meterId].GetValueOrDefault(time); - } - - var amount = evaluator.Evaluate(variables); - if (double.IsNaN(amount) || double.IsInfinity(amount)) - { - amount = 0; // e.g. division by zero in a bucket — don't poison downstream sums - } - - yield return new Consumption - { - MeterId = context.Meter.MeterId, - Time = time, - Amount = amount, - Kind = ConsumptionKind.Consumption, - Quality = ReadingQuality.Estimated, - }; - } - } -} diff --git a/src/Core/Normalization/SourceInterval.cs b/src/Core/Normalization/SourceInterval.cs new file mode 100644 index 0000000..b410f41 --- /dev/null +++ b/src/Core/Normalization/SourceInterval.cs @@ -0,0 +1,69 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization; + +/// +/// The stretch of time a normalized row accrued over (D-10), as the normalizers hand it to +/// and . +/// +/// Where the interval begins; equal to when unknown. +/// The instant the closing reading describes. +/// True when the start is unknown: a first reading with nothing to say since when. +internal readonly record struct SourceInterval(DateTimeOffset Start, DateTimeOffset End, bool OpeningBalance) +{ + /// + /// The interval behind a meter's first reading, which has no previous reading to start from. A month + /// label covers the local month it names — the sheet row is that month's figure (which is why direct + /// deltas use this for every label, not only the first). Otherwise, for a register, the meter's install + /// date says since when it advanced from its baseline. Without either the start is unknown, and the row is + /// an opening balance with a zero-length interval at the reading: its amount is real, but no bucket before + /// the reading can claim to be covered by it (D-14). + /// + /// The first reading. + /// The instant it describes (). + /// The meter, for its install date. + /// The instance zone the install date is a local date in. + /// + /// True for a register, whose first value is everything it counted since it was installed. False for a + /// source that reports increments (direct delta): its first increment covers only the source's own + /// reporting step, which nothing records — claiming the years since installation would make every bucket + /// back to the install date look covered by one small number. + /// + public static SourceInterval First( + Reading reading, DateTimeOffset effective, MeterConfig meter, TimeZoneInfo zone, bool registerSinceInstall) + { + ArgumentNullException.ThrowIfNull(reading); + ArgumentNullException.ThrowIfNull(meter); + ArgumentNullException.ThrowIfNull(zone); + + if (GapAttribution.IsMonthLabel(reading)) + { + return new SourceInterval(GapAttribution.LabelMonthStart(reading, zone), effective, OpeningBalance: false); + } + + if (registerSinceInstall && meter.InstalledAt is { } installed) + { + // An install date after the reading contradicts it; that says nothing about the start either. + var start = GapAttribution.LocalMidnight(installed, zone); + if (start < effective) + { + return new SourceInterval(start, effective, OpeningBalance: false); + } + } + + return new SourceInterval(effective, effective, OpeningBalance: true); + } + + /// + /// Where a row closing this interval is stamped: a month label keeps , + /// anything else (D-11). + /// + public DateTimeOffset Stamp(Reading reading, TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(reading); + + return GapAttribution.IsMonthLabel(reading) + ? GapAttribution.StampTime(reading, zone).ToUniversalTime() + : GapAttribution.CloseStamp(Start, End, zone); + } +} diff --git a/src/Infrastructure/Analysis/AnalysisCatalog.cs b/src/Infrastructure/Analysis/AnalysisCatalog.cs new file mode 100644 index 0000000..be8edbb --- /dev/null +++ b/src/Infrastructure/Analysis/AnalysisCatalog.cs @@ -0,0 +1,374 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Normalization; +using MeterVault.Infrastructure.Persistence; +using MeterVault.Infrastructure.Persistence.Analysis; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Analysis; + +/// +/// One meter as analysis reads it: the entity, its normalized quantity (D-20), its effective role (A-07), its totals +/// override (D-23), whether its stored analysis data is current (D-16) and — for a virtual meter — how its +/// definition stands (D-25 – D-28). +/// +public sealed class AnalysisMeter +{ + internal AnalysisMeter(Meter meter, Tank? tank, MeterRollupState? state, bool isPending) + { + Meter = meter; + Tank = tank; + State = state; + IsPending = isPending; + Role = MeterRoleRules.Effective(meter); + Override = TotalsOverrideTokens.FromMeta(meter.Meta); + Quantity = NormalizedQuantity.Of(meter, tank); + } + + public Meter Meter { get; } + + public Tank? Tank { get; } + + public int Id => Meter.Id; + + public string Name => Meter.Name; + + public int EnergyTypeId => Meter.EnergyTypeId; + + public bool IsVirtual => Meter.Mode == MeterMode.Virtual; + + /// What the meter's amounts measure and in which unit (D-20). + public NormalizedQuantity Quantity { get; internal set; } + + public MeterRole? Role { get; } + + public TotalsOverride Override { get; } + + /// The stored rollup state; null when none was ever written. + public MeterRollupState? State { get; } + + /// + /// True for a physical meter whose stored analysis data is missing, from another normalization revision or cut in + /// another zone (D-16): it reads as "being prepared", never as "no data". A virtual meter is never pending itself — + /// it stores nothing — only through its sources. + /// + public bool IsPending { get; } + + /// What was found in a virtual meter's meta; null for a physical meter. + public VirtualDefinitionReadResult? StoredDefinition { get; internal set; } + + /// How a virtual meter's definition stands; null for a physical meter. + public VirtualMeterStatus? VirtualStatus { get; internal set; } + + /// + /// The definition the reader evaluates: the effective stored one, or a legacy meter's derived sum. For an invalid + /// meter, whatever could be read (for display only). + /// + public VirtualDefinition? Definition { get; internal set; } + + /// The validation of , when there is one. + public VirtualValidation? Validation { get; internal set; } + + /// The legacy derivation, for a virtual meter without a stored expression. + public LegacyDerivation? Legacy { get; internal set; } + + /// The formula the reader evaluates; null unless the meter is valid or a derived legacy sum. + public Formula? Formula => + VirtualStatus is VirtualMeterStatus.Valid or VirtualMeterStatus.Legacy && Validation is { IsValid: true } validation ? validation.Formula : null; + + /// The effective cost rule (D-39); none unless evaluable. + public VirtualCostRule CostRule => Formula is not null && Validation is { } v ? v.CostRule : VirtualCostRule.None; +} + +/// +/// Everything a request needs to know about the meters, loaded once (D-15): the meters with their normalized +/// quantities and rollup states, the topology links, the virtual definitions validated in dependency order, the +/// calculation graph and the totals classification (D-22). +/// +/// +/// +/// Virtual definitions are read tolerant of bad data and validated again on every read (D-26), because a source's +/// unit, kind or definition can change after the virtual meter was saved. Validation runs in dependency order and +/// feeds each meter's effective kind and unit into the next, so a sum over a sum sees what its source measures. An +/// expression-less legacy meter is evaluated as the sum its links imply (D-28) until the startup conversion has +/// stored it; a malformed one is never derived — its links must not overwrite a broken formula. +/// +/// +/// The totals classification is made without tariffs: separately billed subsections (D-35) change the bill, not the +/// quantity measures. A cost reader classifies again with its tariff book (). +/// +/// +public sealed class AnalysisCatalog +{ + private readonly Dictionary _meters; + private readonly List _totalsMeters; + private readonly List _totalsLinks; + + private AnalysisCatalog( + TimeZoneInfo zone, + Dictionary meters, + IReadOnlyList links, + MeterCatalog catalog, + DependencyGraph graph) + { + Zone = zone; + _meters = meters; + Links = links; + Catalog = catalog; + Graph = graph; + _totalsMeters = [.. meters.Values.OrderBy(m => m.Id).Select(ToTotalsMeter)]; + _totalsLinks = [.. links.Select(l => new TotalsLink(l.FromMeterId, l.ToMeterId))]; + Totals = TotalsPolicy.Classify(_totalsMeters, _totalsLinks); + } + + /// The instance zone the catalog was read for. + public TimeZoneInfo Zone { get; } + + /// Every meter, by id. + public IReadOnlyDictionary Meters => _meters; + + /// Every topology link. + public IReadOnlyList Links { get; } + + /// The meters as the virtual-meter logic sees them, with effective kinds and units. + public MeterCatalog Catalog { get; } + + /// The calculation dependencies between virtual meters (legacy sums included). + public DependencyGraph Graph { get; } + + /// The totals classification (D-22), made without tariffs. + public TotalsClassification Totals { get; } + + /// The meters' totals inputs, for a caller that classifies again (). + public IReadOnlyList TotalsMeters => _totalsMeters; + + public AnalysisMeter? Find(int meterId) => _meters.GetValueOrDefault(meterId); + + /// Classifies again with a tariff predicate (D-35), e.g. TariffBook.HasMeterScopedUnitPrice. + public TotalsClassification Classify(Func? hasMeterScopedUnitPrice) => + TotalsPolicy.Classify(_totalsMeters, _totalsLinks, hasMeterScopedUnitPrice); + + /// The existing physical meters finally read, ascending. + public IReadOnlyList PhysicalLeaves(IEnumerable meterIds) => + [.. Graph.PhysicalLeaves(meterIds).Where(id => _meters.TryGetValue(id, out var m) && !m.IsVirtual)]; + + /// Loads the catalog from the database (meters, tanks, links, rollup states). + public static async Task LoadAsync(MeterVaultDbContext db, TimeZoneInfo zone, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(zone); + + var meters = await db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); + var tanks = await db.Tanks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); + var links = await db.MeterLinks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); + var states = await db.MeterRollupStates.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); + return Build(meters, tanks, links, states, zone); + } + + /// Builds the catalog from loaded rows. + public static AnalysisCatalog Build( + IEnumerable meters, + IEnumerable tanks, + IEnumerable links, + IEnumerable states, + TimeZoneInfo zone) + { + ArgumentNullException.ThrowIfNull(meters); + ArgumentNullException.ThrowIfNull(tanks); + ArgumentNullException.ThrowIfNull(links); + ArgumentNullException.ThrowIfNull(states); + ArgumentNullException.ThrowIfNull(zone); + + var tankOf = tanks.GroupBy(t => t.MeterId).ToDictionary(g => g.Key, g => g.First()); + var stateOf = states.ToDictionary(s => s.MeterId); + var linkList = links.ToList(); + + var entries = new Dictionary(); + foreach (var meter in meters) + { + var state = stateOf.GetValueOrDefault(meter.Id); + var pending = meter.Mode != MeterMode.Virtual && IsOutdated(state, zone); + entries[meter.Id] = new AnalysisMeter(meter, meter.Mode == MeterMode.Virtual ? null : tankOf.GetValueOrDefault(meter.Id), state, pending); + } + + var catalog = ReadDefinitions(entries, linkList); + catalog = ValidateInOrder(entries, catalog); + + return new AnalysisCatalog(zone, entries, linkList, catalog, DependencyGraph.FromCatalog(catalog)); + } + + /// Missing, from an older revision, or cut in another zone than the reader's (D-16). + internal static bool IsOutdated(MeterRollupState? state, TimeZoneInfo zone) => + state is null + || state.Revision < NormalizationUpgrade.CurrentRevision + || !string.Equals(state.Zone, zone.Id, StringComparison.Ordinal); + + /// + /// Reads every virtual meter's stored definition and derives the legacy ones, returning the catalog with the + /// declared (not yet validated) kinds and units. + /// + private static MeterCatalog ReadDefinitions(Dictionary entries, List links) + { + var legacy = new List(); + foreach (var entry in entries.Values.Where(e => e.IsVirtual)) + { + var read = VirtualDefinitionJson.Read(entry.Meter.Meta); + entry.StoredDefinition = read; + switch (read.Status) + { + case VirtualDefinitionReadStatus.Present: + entry.Definition = read.Definition; + entry.VirtualStatus = VirtualMeterStatus.Valid; + break; + case VirtualDefinitionReadStatus.Malformed: + entry.Definition = read.Definition; + entry.VirtualStatus = VirtualMeterStatus.Malformed; + break; + default: + legacy.Add(entry.Id); + break; + } + + entry.Quantity = NormalizedQuantity.Of(entry.Meter, tank: null, entry.Definition?.DeclaredResult); + } + + var catalog = new MeterCatalog(entries.Values.Select(CatalogEntry)); + if (legacy.Count == 0) + { + return catalog; + } + + // A malformed meter is kept out of the derivation: with no readable definition it would look legacy, and its + // links must not replace a broken formula. The run is in dependency order and sees each derived sum. + var run = LegacyVirtualDerivation.DeriveAll(legacy, links, catalog); + foreach (var derivation in run.Results) + { + var entry = entries[derivation.MeterId]; + entry.Legacy = derivation; + if (derivation is { IsDerived: true, Definition: { } derived }) + { + entry.Definition = derived; + entry.VirtualStatus = VirtualMeterStatus.Legacy; + entry.Quantity = NormalizedQuantity.Of(entry.Meter, tank: null, derived.DeclaredResult); + catalog = catalog.With(CatalogEntry(entry)); + } + else + { + entry.VirtualStatus = VirtualMeterStatus.NeedsConfiguration; + } + } + + return catalog; + } + + /// Validates every virtual definition, dependencies first, and records the effective kinds and units. + private static MeterCatalog ValidateInOrder(Dictionary entries, MeterCatalog catalog) + { + var graph = DependencyGraph.FromCatalog(catalog); + var ordered = graph.EvaluationOrder.Concat(graph.VirtualMeters.Where(id => !graph.IsEvaluable(id))).ToList(); + foreach (var id in ordered) + { + var entry = entries[id]; + if (entry.Definition is not { } definition || entry.VirtualStatus is VirtualMeterStatus.Malformed or VirtualMeterStatus.NeedsConfiguration) + { + continue; + } + + var validation = VirtualValidator.Validate(definition, id, catalog); + entry.Validation = validation; + if (validation.EffectiveDefinition is { } effective) + { + entry.Definition = effective; + entry.Quantity = NormalizedQuantity.Of(entry.Meter, tank: null, effective.DeclaredResult); + catalog = catalog.With(CatalogEntry(entry)); + } + else if (entry.VirtualStatus == VirtualMeterStatus.Valid) + { + entry.VirtualStatus = VirtualMeterStatus.Invalid; + } + else + { + entry.VirtualStatus = VirtualMeterStatus.NeedsConfiguration; + } + } + + return catalog; + } + + private static CatalogMeter CatalogEntry(AnalysisMeter meter) => new( + meter.Id, + meter.Name, + meter.Meter.Mode, + meter.Quantity.Kind, + meter.Quantity.Unit, + meter.Meter.EnergyTypeId, + meter.IsVirtual ? meter.Definition : null, + meter.Meter.InstalledAt, + meter.Meter.RetiredAt); + + private TotalsMeter ToTotalsMeter(AnalysisMeter meter) + { + var formula = meter.Formula; + var usable = meter.IsVirtual && formula is not null; + return new TotalsMeter( + meter.Id, + meter.Name, + meter.EnergyTypeId, + meter.Meter.Mode, + meter.Role, + meter.Quantity.Kind, + meter.Quantity.Unit, + meter.IsVirtual, + meter.Override, + meter.Meter.InstalledAt, + meter.Meter.RetiredAt, + usable ? ExpandSources(meter.Id, []) : [], + usable && IsPureSumThroughout(meter.Id, []), + usable ? meter.Validation?.Kind : null); + } + + /// A virtual meter's physical sources with multiplicity (A + A lists A twice), through nested sums. + private List ExpandSources(int meterId, HashSet visiting) + { + var result = new List(); + if (!_meters.TryGetValue(meterId, out var meter) || !meter.IsVirtual) + { + result.Add(meterId); + return result; + } + + if (meter.Formula is not { } formula || !visiting.Add(meterId)) + { + return result; + } + + foreach (var reference in formula.References) + { + result.AddRange(ExpandSources(reference.MeterId, visiting)); + } + + visiting.Remove(meterId); + return result; + } + + /// True when the formula is a pure sum and so is every nested virtual source's (D-23). + private bool IsPureSumThroughout(int meterId, HashSet visiting) + { + if (!_meters.TryGetValue(meterId, out var meter) || !meter.IsVirtual) + { + return true; + } + + if (meter.Formula is not { IsPureSum: true } formula || !visiting.Add(meterId)) + { + return false; + } + + var pure = formula.MeterIds.All(id => IsPureSumThroughout(id, visiting)); + visiting.Remove(meterId); + return pure; + } +} diff --git a/src/Infrastructure/Analysis/AnalysisModels.cs b/src/Infrastructure/Analysis/AnalysisModels.cs new file mode 100644 index 0000000..35d8424 --- /dev/null +++ b/src/Infrastructure/Analysis/AnalysisModels.cs @@ -0,0 +1,487 @@ +using System.Globalization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Analysis.Virtual; + +namespace MeterVault.Infrastructure.Analysis; + +// The shapes the shared analysis reader (AnalysisReader) hands to pages, the API and the CSV export (D-15, D-19, +// D-27, brief §4.3). Everything is data: codes the UI localizes and ids it names, never prose. One series shape +// serves a meter and a total alike, so a chart, a table and an export treat both the same way. + +/// What a request analyses. +public enum AnalysisScopeKind +{ + /// Every energy type's totals (the overview). + Portfolio, + + /// One energy type's totals. + EnergyType, + + /// Explicitly chosen meters, physical or virtual, each its own series. + Meters, +} + +/// The scope of an analysis request (D-47 scope=portfolio|type|meter|meters). +public sealed class AnalysisScope : IEquatable +{ + private AnalysisScope(AnalysisScopeKind kind, int? energyTypeId, IReadOnlyList meterIds) + { + Kind = kind; + EnergyTypeId = energyTypeId; + MeterIds = meterIds; + } + + public static AnalysisScope Portfolio { get; } = new(AnalysisScopeKind.Portfolio, null, []); + + public AnalysisScopeKind Kind { get; } + + /// The energy type of an scope. + public int? EnergyTypeId { get; } + + /// The meters of a scope, distinct, in the order given. + public IReadOnlyList MeterIds { get; } + + public static AnalysisScope ForEnergyType(int energyTypeId) => new(AnalysisScopeKind.EnergyType, energyTypeId, []); + + public static AnalysisScope ForMeter(int meterId) => new(AnalysisScopeKind.Meters, null, [meterId]); + + public static AnalysisScope ForMeters(IEnumerable meterIds) + { + ArgumentNullException.ThrowIfNull(meterIds); + + return new AnalysisScope(AnalysisScopeKind.Meters, null, [.. meterIds.Distinct()]); + } + + public bool Equals(AnalysisScope? other) => + other is not null && Kind == other.Kind && EnergyTypeId == other.EnergyTypeId && MeterIds.SequenceEqual(other.MeterIds); + + public override bool Equals(object? obj) => Equals(obj as AnalysisScope); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Kind); + hash.Add(EnergyTypeId); + foreach (var id in MeterIds) + { + hash.Add(id); + } + + return hash.ToHashCode(); + } + + public override string ToString() => Kind switch + { + AnalysisScopeKind.Portfolio => "portfolio", + AnalysisScopeKind.EnergyType => string.Create(CultureInfo.InvariantCulture, $"type:{EnergyTypeId}"), + _ => "meters:" + string.Join(',', MeterIds.Select(id => id.ToString(CultureInfo.InvariantCulture))), + }; +} + +/// The limits a request is checked against before any SQL runs (D-15, D-47). +public static class AnalysisLimits +{ + /// The most meters an explicit selection may chart side by side. + public const int MaxSeries = 6; + + /// The most buckets a series may have (D-05). + public const int MaxPoints = BucketPlanner.DefaultMaxPoints; +} + +/// +/// One analysis request: a scope, a period resolved once by the caller (D-01, D-03), the bucket size and an optional +/// comparison. +/// +/// What to analyse. +/// The period, resolved in the instance zone; its is the request's "now". +public sealed record AnalysisRequest(AnalysisScope Scope, ResolvedPeriod Period) +{ + /// The requested bucket size; lets the reader choose from the data (D-05). + public BucketSize Bucket { get; init; } = BucketSize.Auto; + + /// + /// Buckets planned by the caller, used as they are instead of — e.g. cost parts. They must + /// tile . + /// + public BucketPlan? Plan { get; init; } + + /// What to compare with (D-06); none by default. + public ComparisonRequest Comparison { get; init; } = ComparisonRequest.None; + + /// + /// For a type or portfolio scope, also one series per meter (the type's meter table, "individual meters"). An + /// explicit meter scope always has them. + /// + public bool IncludeMeterSeries { get; init; } + + /// The point limit; by default. + public int MaxPoints { get; init; } = AnalysisLimits.MaxPoints; + + /// + /// The most meters an explicit selection may hold; by default (D-47). The + /// limit is the chart's; a caller that reads more meters than it charts (the bill, an export) may raise it. + /// + public int MaxSeries { get; init; } = AnalysisLimits.MaxSeries; + + /// + /// Reads the physical series of a meter selection as contributors to a total or a bill: outside a meter's service + /// period (D-24) its value is a known zero, as it is inside a measure — not missing, as on the meter's own page. + /// Virtual series read their sources this way anyway. + /// + internal bool AsContributors { get; init; } + + /// + /// Reads quantities and their availability only, for a caller that prices them (the cost reader): no freshness + /// (so no read of reading) and no cost availability, which the cost reader derives from its own bill. + /// + internal bool QuantitiesOnly { get; init; } +} + +/// Why a request was refused without reading anything. +public enum AnalysisRefusal +{ + None, + + /// More meters than were selected. + TooManySeries, + + /// The bucket size would exceed the point limit; names a coarser one. + TooManyPoints, +} + +/// A meter's own series, or the total of a measure. +public enum SeriesKind +{ + Meter, + Measure, +} + +/// +/// The stable identity of a series (brief §4.3): a meter, or one unit group of one measure of one energy type. +/// is an invariant token for URLs, chart keys and the CSV export. +/// +public sealed record SeriesKey(SeriesKind Kind, int? MeterId, int? EnergyTypeId, TotalsMeasure? Measure, string Unit) +{ + public static SeriesKey ForMeter(int meterId, int energyTypeId, string unit) => new(SeriesKind.Meter, meterId, energyTypeId, null, unit); + + public static SeriesKey ForMeasure(int energyTypeId, TotalsMeasure measure, string unit) => + new(SeriesKind.Measure, null, energyTypeId, measure, unit); + + /// m12 for a meter, t3:use:kWh for a measure. + public string Id => Kind == SeriesKind.Meter + ? string.Create(CultureInfo.InvariantCulture, $"m{MeterId}") + : string.Create(CultureInfo.InvariantCulture, $"t{EnergyTypeId}:{MeasureToken(Measure)}:{Unit}"); + + /// The invariant token of a measure. + public static string MeasureToken(TotalsMeasure? measure) => measure switch + { + TotalsMeasure.Use => "use", + TotalsMeasure.GridImport => "grid-import", + TotalsMeasure.Export => "export", + TotalsMeasure.Generation => "generation", + TotalsMeasure.Runtime => "runtime", + _ => string.Empty, + }; +} + +/// Where a series' values come from. +public enum SeriesBasis +{ + /// A physical meter's rollups. + Physical, + + /// A virtual meter's formula, evaluated on read (D-27). + Virtual, + + /// An expression-less virtual meter evaluated as the sum its links imply, until it is confirmed (D-28). + LegacyVirtual, + + /// The members of a measure added up (D-22). + Measure, +} + +/// How a virtual meter's definition stands on read (D-26, D-28). +public enum VirtualMeterStatus +{ + /// A stored definition that validates. + Valid, + + /// No stored expression, but its links imply an unambiguous sum, which is evaluated ("legacy — confirm"). + Legacy, + + /// No stored expression, and its links imply nothing evaluable ("needs configuration"). + NeedsConfiguration, + + /// The stored JSON cannot be trusted. + Malformed, + + /// A stored definition that fails validation (syntax, references, a loop, kinds or units). + Invalid, +} + +/// A virtual meter's calculation as the reader evaluated it. +/// How its definition stands. +/// The expression evaluated (for a legacy meter the derived sum), or the stored one when invalid. +/// The effective cost rule (D-39); when invalid. +/// The meters the formula refers to, ascending. +/// The physical meters it finally reads, ascending. +/// Validation findings; empty when valid. +/// The legacy derivation, for a meter without a stored expression. +/// What is wrong with malformed stored JSON (data, not prose for the UI). +public sealed record VirtualSeriesInfo( + VirtualMeterStatus Status, + string? Expression, + VirtualCostRule CostRule, + IReadOnlyList DirectSources, + IReadOnlyList PhysicalLeaves, + IReadOnlyList Problems, + LegacyDerivation? Legacy, + string? Malformation); + +/// +/// One source's part in a virtual result (D-27): its own series and status, what entered the formula, and the +/// dependency path from the evaluated meter to it. A nested virtual source carries its own sources in +/// . +/// +public sealed record SeriesContribution( + int MeterId, + string Name, + bool IsVirtual, + double? Coefficient, + IReadOnlyList Values, + IReadOnlyList UsedAmounts, + BucketValue Total, + double? UsedTotal, + IReadOnlyList DependencyPath, + IReadOnlyList Nested); + +/// +/// Rows of one physical meter that belong to the requested range but close after now (D-04, A-05): a current-month +/// label row, a future-stamped reading. They are left out of actuals and reported here. +/// +/// The physical meter. +/// How many consumption rows. +/// Their amount, in the meter's normalized unit. +/// The first local day holding such a row. +/// The last one. +public sealed record RecordedAfterNow(int MeterId, int Rows, double Amount, DateOnly FirstDay, DateOnly LastDay); + +/// +/// A series in the comparison period (D-07, D-08): its values per paired bucket, its total over the comparison's +/// requested range, the coverage both periods share, and the change over that shared coverage. +/// +/// One value per paired comparison bucket (). +/// The total over the whole comparison range. +/// The coverage both periods share; not comparable when empty. +/// The current value over the matched range; null when not comparable or not evaluable. +/// The comparison value over its matched range. +/// The change over the matched range; unavailable when not comparable (absolute values only). +public sealed record SeriesComparison( + IReadOnlyList Values, + BucketValue Total, + MatchedCoverageResult Matched, + double? CurrentMatched, + double? ComparisonMatched, + Change Change); + +/// +/// One series of an analysis result: a meter or a measure total, its values per bucket, its period total and +/// everything that qualifies them (brief §4.3) — availability, provenance (inside each value), freshness, rows +/// recorded after now, contributions and problems. +/// +/// Stable identity. +/// The meter's name (user data, never translated); empty for a measure, whose name the UI localizes. +/// Where the values come from. +/// What the values measure (D-20). +/// The normalized unit of every value (D-20). +/// One value per bucket of . +/// The period total: for a physical or summed series the sum over the period with the period's own +/// status (a monthly import resolves the month although not its days); for a virtual meter the formula over its +/// sources' totals (D-27). +/// True when the buckets add up to the total; false for a ratio or a formula with a constant. +public sealed record AnalysisSeries( + SeriesKey Key, + string Name, + SeriesBasis Basis, + QuantityKind Kind, + string Unit, + IReadOnlyList Values, + BucketValue Total, + bool IsAdditive) +{ + /// The meter, for a meter series. + public int? MeterId => Key.MeterId; + + /// The energy type the series belongs to. + public int? EnergyTypeId => Key.EnergyTypeId; + + /// The coverage behind each bucket of a physical meter (tooltips, D-14); null for other series. + public IReadOnlyList? Coverage { get; init; } + + /// The coarsest resolution of the data covering the period; null without coverage. + public ResolutionClass? Resolution { get; init; } + + /// The dates the series has data for, capped at now (D-19). + public AvailableRange? Availability { get; init; } + + /// How current the data is (D-18). + public Freshness Freshness { get; init; } = Freshness.None; + + /// Rows in the requested range that close after now, per physical meter (D-04). + public IReadOnlyList RecordedAfterNow { get; init; } = []; + + /// For a virtual meter, every source's series and part in the result (D-27). + public IReadOnlyList Contributions { get; init; } = []; + + /// For a measure, the meters counted in it (D-22). + public IReadOnlyList MemberIds { get; init; } = []; + + /// For a meter, its place in its energy type's totals and why (D-22, D-23). + public MeterTotalsEntry? Totals { get; init; } + + /// For a virtual meter, its calculation. + public VirtualSeriesInfo? Virtual { get; init; } + + /// What qualifies the normalized quantity (a fixed-rate estimate, an undeclared result, …). + public QuantityNotes Notes { get; init; } + + /// The series in the comparison period, when one was requested and applies. + public SeriesComparison? Comparison { get; init; } + + /// True when the series' data is being rebuilt (D-16): "analysis being prepared", never "no data". + public bool IsPending => Total.Status == BucketStatus.Pending; +} + +/// A meter's place in its energy type's totals, with its name for display. +public sealed record MeterClassification(int MeterId, string Name, MeterTotalsEntry Entry); + +/// An attention item (D-53) or a note about the result; a code the UI localizes. +public enum AnalysisProblemKind +{ + /// A meter's analysis data is being (re)built (D-16). + AnalysisPending, + + /// A requested meter does not exist. + UnknownMeter, + + /// A virtual definition fails validation (D-26). + InvalidDefinition, + + /// A virtual meter's stored JSON cannot be read. + MalformedDefinition, + + /// A legacy virtual meter is evaluated as its implied sum until it is confirmed (D-28). + LegacyDefinition, + + /// A legacy virtual meter's links imply nothing evaluable (D-28 "needs configuration"). + LegacyNeedsConfiguration, + + /// Rows in the range close after now and were left out of actuals (D-04). + RecordedAfterNow, + + /// A live source has stopped delivering (D-18). + StaleSource, + + /// The totals configuration contradicts itself (a duplicate role, a refused override, …). + TotalsProblem, + + /// The configuration cannot rule out an overlap (D-53). + PossibleOverlap, +} + +/// One problem: its kind, the meter it is about, and the data behind it. +public sealed record AnalysisProblem(AnalysisProblemKind Kind, int? MeterId) +{ + /// Further meters involved (a dependency path, the other holder of a role). + public IReadOnlyList MeterIds { get; init; } = []; + + /// Data values (units, kinds); never prose. + public IReadOnlyList Values { get; init; } = []; + + public VirtualProblem? Virtual { get; init; } + + public TotalsProblem? Totals { get; init; } + + public OverlapHint? Hint { get; init; } + + public LegacyDerivationOutcome? Legacy { get; init; } + + public RecordedAfterNow? AfterNow { get; init; } +} + +/// What the latest period with data rests on (D-19). +public enum LatestPeriodBasis +{ + Meters, + Manual, + Both, +} + +/// The latest local month with data, and what it rests on (D-19). +public sealed record LatestPeriod(DateOnly Month, LatestPeriodBasis Basis); + +/// +/// What a scope has data for (D-19), capped at now: the quantity scope's coverage, the cost scope's (billed meters' +/// coverage plus manual costs), and the latest month with data. The all preset spans +/// (or on a cost view). +/// +public sealed record ScopeAvailability(AvailableRange? Quantity, AvailableRange? Cost, LatestPeriod? Latest) +{ + public static ScopeAvailability None { get; } = new(null, null, null); + + /// The latest month of quantity data, which rests on meters only. + public LatestPeriod? LatestQuantity => Quantity is { } range ? new LatestPeriod(range.LatestMonth, LatestPeriodBasis.Meters) : null; +} + +/// The comparison of a result (D-06): how it was resolved, the period it reads, and the paired buckets. +/// The comparison, or why there is none. +/// The comparison as a resolved period (its requested range); null when not applicable. +/// Each current bucket paired with its image, by index (A-10). +public sealed record AnalysisComparison(ComparisonResolution Resolution, ResolvedPeriod? Period, IReadOnlyList Buckets) +{ + public bool IsApplicable => Resolution.IsApplicable; +} + +/// +/// The result of one analysis request: the buckets it was read in, the meter series and the measure totals, the +/// scope's availability and its attention items. +/// +/// The request, with its resolved period — the requested range every series shares. +/// The buckets; for auto, the size chosen. +/// Meter series: the selected meters, or every meter of a type/portfolio when asked for. +/// For a type or portfolio scope, each measure's total per unit (D-22). +/// The scope's data range and latest month with data (D-19). +/// Attention items (D-53). +public sealed record AnalysisResult( + AnalysisRequest Request, + BucketPlan Plan, + IReadOnlyList Series, + IReadOnlyList Measures, + ScopeAvailability Availability, + IReadOnlyList Problems) +{ + /// Set when the request was refused before anything was read. + public AnalysisRefusal Refusal { get; init; } + + /// The comparison, when one was requested. + public AnalysisComparison? Comparison { get; init; } + + /// Every meter of the scope's energy types, classified (type and portfolio scopes). + public IReadOnlyList Classification { get; init; } = []; + + /// The totals problems and overlap hints are in ; this is the requested range. + public ResolvedPeriod Period => Request.Period; + + /// The whole range lies after now (D-04): nothing has happened yet. + public bool NotYetOccurred => Period.HasNotStarted(); + + /// The series with , or null. + public AnalysisSeries? SeriesFor(int meterId) => Series.FirstOrDefault(s => s.MeterId == meterId); + + /// The measure series of a type, measure and (optionally) unit, or null. + public AnalysisSeries? MeasureFor(int energyTypeId, TotalsMeasure measure, string? unit = null) => + Measures.FirstOrDefault(s => s.EnergyTypeId == energyTypeId && s.Key.Measure == measure && (unit is null || Units.AreSame(s.Unit, unit))); +} diff --git a/src/Infrastructure/Analysis/AnalysisQueries.cs b/src/Infrastructure/Analysis/AnalysisQueries.cs new file mode 100644 index 0000000..ca97e4b --- /dev/null +++ b/src/Infrastructure/Analysis/AnalysisQueries.cs @@ -0,0 +1,379 @@ +using System.Data.Common; +using System.Globalization; +using System.Text; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Rollups; +using MeterVault.Core.Domain; +using Npgsql; + +namespace MeterVault.Infrastructure.Analysis; + +/// +/// The reader's SQL (D-15): one query per table, every meter at once (meter_id = ANY(@ids)), bounded by the +/// windows a request registered. Plain Npgsql, because the parameters are arrays of dates and instants. +/// +internal static class AnalysisQueries +{ + /// Beyond any date a row could be filed under; bounds the open-ended "recorded after now" window. + private static readonly DateOnly OpenEnd = new(9999, 12, 31); + + /// The stored coverage runs of , per meter, oldest first. + public static async Task>> CoverageAsync( + DbConnection connection, IReadOnlyCollection meterIds, CancellationToken cancellationToken) + { + var result = new Dictionary>(); + if (meterIds.Count == 0) + { + return result; + } + + const string sql = """ + SELECT meter_id, span_from, span_to, resolution_class, divided_at_months, gap_reason, last_interval_start + FROM meter_coverage + WHERE meter_id = ANY(@ids) + ORDER BY meter_id, span_from + """; + + await using var command = Command(connection, sql); + command.Parameters.AddWithValue("ids", meterIds.ToArray()); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var meterId = reader.GetInt32(0); + if (!result.TryGetValue(meterId, out var runs)) + { + result[meterId] = runs = []; + } + + runs.Add(new CoverageRun( + reader.GetFieldValue(1), + reader.GetFieldValue(2), + (ResolutionClass)reader.GetInt16(3), + reader.GetBoolean(4), + (CoverageGapReason)reader.GetInt16(5), + reader.IsDBNull(6) ? null : reader.GetFieldValue(6))); + } + + return result; + } + + /// + /// Where each meter's opening balance is booked (A-01): the stamp of its first consumption row, for the meters + /// whose first rollup day carries the opening-balance flag. An opening balance is a meter's first reading, so it + /// is always its first row. + /// + public static async Task> OpeningBalancesAsync( + DbConnection connection, IReadOnlyCollection meterIds, CancellationToken cancellationToken) + { + var result = new Dictionary(); + if (meterIds.Count == 0) + { + return result; + } + + const string sql = """ + SELECT m.id, c.time + FROM unnest(@ids) AS m(id) + CROSS JOIN LATERAL ( + SELECT r.flags FROM consumption_rollup r WHERE r.meter_id = m.id ORDER BY r.day LIMIT 1) f + CROSS JOIN LATERAL ( + SELECT c.time FROM consumption c WHERE c.meter_id = m.id ORDER BY c.time LIMIT 1) c + WHERE (f.flags & @flag) <> 0 + """; + + await using var command = Command(connection, sql); + command.Parameters.AddWithValue("ids", meterIds.ToArray()); + command.Parameters.AddWithValue("flag", (int)RollupFlags.OpeningBalance); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + result[reader.GetInt32(0)] = reader.GetFieldValue(1); + } + + return result; + } + + /// + /// The day rollups each leaf registered (as contiguous day ranges), and — for the "recorded after now" block + /// (D-04) — the days from to whose rows close after + /// . + /// + public static async Task DayRollupsAsync( + DbConnection connection, + IReadOnlyDictionary leaves, + IReadOnlyCollection afterIds, + DateOnly afterFrom, + DateOnly? afterTo, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var (ids, froms, tos) = Ranges(leaves.Values, leaf => leaf.RequestedDays, day => day.AddDays(1)); + if (ids.Length == 0 && afterIds.Count == 0) + { + return; + } + + const string sql = """ + SELECT r.meter_id, r.day, r.kind, r.amount, r.measured, r.manual, r.imported, r.estimated, r.rows, r.flags, + r.max_interval_end, false AS after_now + FROM unnest(@ids, @froms, @tos) AS w(meter_id, from_day, to_day) + JOIN consumption_rollup r ON r.meter_id = w.meter_id AND r.day >= w.from_day AND r.day < w.to_day + UNION ALL + SELECT r.meter_id, r.day, r.kind, r.amount, r.measured, r.manual, r.imported, r.estimated, r.rows, r.flags, + r.max_interval_end, true AS after_now + FROM consumption_rollup r + WHERE r.meter_id = ANY(@after_ids) AND r.day >= @after_from AND r.day < @after_to AND r.max_interval_end > @now + """; + + await using var command = Command(connection, sql); + command.Parameters.AddWithValue("ids", ids); + command.Parameters.AddWithValue("froms", froms); + command.Parameters.AddWithValue("tos", tos); + command.Parameters.AddWithValue("after_ids", afterIds.ToArray()); + command.Parameters.AddWithValue("after_from", afterFrom); + command.Parameters.AddWithValue("after_to", afterTo ?? OpenEnd); + command.Parameters.AddWithValue("now", now.ToUniversalTime()); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var leaf = leaves[reader.GetInt32(0)]; + var day = reader.GetFieldValue(1); + var bucket = ReadBucket(reader, day); + if (reader.GetBoolean(11)) + { + var aggregate = new RollupAggregate(); + aggregate.Add(bucket); + leaf.AfterNowDays.Add((day, aggregate)); + } + else + { + leaf.AddDay(day, bucket); + } + } + } + + /// The month rollups each leaf registered, as contiguous month ranges. + public static async Task MonthRollupsAsync( + DbConnection connection, IReadOnlyDictionary leaves, CancellationToken cancellationToken) + { + var (ids, froms, tos) = Ranges(leaves.Values, leaf => leaf.RequestedMonths, month => month.AddMonths(1)); + if (ids.Length == 0) + { + return; + } + + const string sql = """ + SELECT r.meter_id, r.month, r.kind, r.amount, r.measured, r.manual, r.imported, r.estimated, r.rows, r.flags, + r.max_interval_end + FROM unnest(@ids, @froms, @tos) AS w(meter_id, from_month, to_month) + JOIN consumption_rollup_month r ON r.meter_id = w.meter_id AND r.month >= w.from_month AND r.month < w.to_month + """; + + await using var command = Command(connection, sql); + command.Parameters.AddWithValue("ids", ids); + command.Parameters.AddWithValue("froms", froms); + command.Parameters.AddWithValue("tos", tos); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var month = reader.GetFieldValue(1); + leaves[reader.GetInt32(0)].AddMonth(month, ReadBucket(reader, month)); + } + } + + /// + /// The consumption rows of the registered partial days (D-15): one bounded window per distinct local day, each + /// for the meters that need it, in one statement. + /// + public static async Task RawDaysAsync( + DbConnection connection, IReadOnlyDictionary leaves, TimeZoneInfo zone, CancellationToken cancellationToken) + { + var byDay = leaves.Values + .SelectMany(leaf => leaf.RequestedRawDays.Select(day => (Day: day, leaf.Id))) + .GroupBy(p => p.Day) + .OrderBy(g => g.Key) + .ToList(); + if (byDay.Count == 0) + { + return; + } + + var sql = new StringBuilder(); + await using var command = Command(connection, string.Empty); + for (var i = 0; i < byDay.Count; i++) + { + if (i > 0) + { + sql.AppendLine("UNION ALL"); + } + + var index = i.ToString(CultureInfo.InvariantCulture); + sql.Append("SELECT meter_id, time, amount, quality FROM consumption WHERE meter_id = ANY(@i").Append(index) + .Append(") AND time >= @f").Append(index).Append(" AND time < @t").Append(index).AppendLine(); + command.Parameters.AddWithValue("i" + index, byDay[i].Select(p => p.Id).Distinct().ToArray()); + command.Parameters.AddWithValue("f" + index, Core.Normalization.GapAttribution.LocalMidnight(byDay[i].Key, zone).ToUniversalTime()); + command.Parameters.AddWithValue("t" + index, Core.Normalization.GapAttribution.LocalMidnight(byDay[i].Key.AddDays(1), zone).ToUniversalTime()); + } + + command.CommandText = sql.ToString(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + leaves[reader.GetInt32(0)].AddRaw(new RawRow( + reader.GetFieldValue(1), reader.GetDouble(2), (ReadingQuality)reader.GetInt16(3))); + } + } + + /// + /// The sums of the exact partial-day windows the leaves registered for summing (D-15): one statement for every + /// window of every meter, returning a tally per window instead of its rows. + /// + public static async Task WindowSumsAsync( + DbConnection connection, IReadOnlyDictionary leaves, CancellationToken cancellationToken) + { + var windows = leaves.Values.SelectMany(leaf => leaf.RequestedWindows.Select(w => (leaf.Id, w.From, w.To))).ToList(); + if (windows.Count == 0) + { + return; + } + + const string sql = """ + SELECT w.idx, count(*)::int, sum(c.amount), + coalesce(sum(c.amount) FILTER (WHERE c.quality = @measured), 0), + coalesce(sum(c.amount) FILTER (WHERE c.quality = @manual), 0), + coalesce(sum(c.amount) FILTER (WHERE c.quality = @imported), 0), + coalesce(sum(c.amount) FILTER (WHERE c.quality NOT IN (@measured, @manual, @imported)), 0) + FROM unnest(@ids, @froms, @tos) WITH ORDINALITY AS w(meter_id, from_time, to_time, idx) + JOIN consumption c ON c.meter_id = w.meter_id AND c.time >= w.from_time AND c.time < w.to_time + GROUP BY w.idx + """; + + await using var command = Command(connection, sql); + command.Parameters.AddWithValue("ids", windows.Select(w => w.Id).ToArray()); + command.Parameters.AddWithValue("froms", windows.Select(w => w.From.ToUniversalTime()).ToArray()); + command.Parameters.AddWithValue("tos", windows.Select(w => w.To.ToUniversalTime()).ToArray()); + command.Parameters.AddWithValue("measured", (short)ReadingQuality.Measured); + command.Parameters.AddWithValue("manual", (short)ReadingQuality.Manual); + command.Parameters.AddWithValue("imported", (short)ReadingQuality.Imported); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var (id, from, to) = windows[(int)reader.GetInt64(0) - 1]; + leaves[id].SetWindowSum(from, to, new WindowSum( + reader.GetDouble(2), reader.GetDouble(3), reader.GetDouble(4), reader.GetDouble(5), reader.GetDouble(6), reader.GetInt32(1))); + } + } + + /// The latest reading times per meter (D-18). + public static async Task>> RecentReadingsAsync( + DbConnection connection, IReadOnlyCollection meterIds, CancellationToken cancellationToken) + { + var result = new Dictionary>(); + if (meterIds.Count == 0) + { + return result; + } + + const string sql = """ + SELECT m.id, r.time + FROM unnest(@ids) AS m(id) + CROSS JOIN LATERAL ( + SELECT time FROM reading WHERE meter_id = m.id ORDER BY time DESC LIMIT @count) r + """; + + await using var command = Command(connection, sql); + command.Parameters.AddWithValue("ids", meterIds.ToArray()); + command.Parameters.AddWithValue("count", FreshnessRules.RecentReadingCount); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var meterId = reader.GetInt32(0); + if (!result.TryGetValue(meterId, out var times)) + { + result[meterId] = times = []; + } + + times.Add(reader.GetFieldValue(1)); + } + + return result; + } + + /// The latest event time per meter (D-18). + public static async Task> LastEventsAsync( + DbConnection connection, IReadOnlyCollection meterIds, CancellationToken cancellationToken) + { + var result = new Dictionary(); + if (meterIds.Count == 0) + { + return result; + } + + const string sql = "SELECT meter_id, max(time) FROM meter_event WHERE meter_id = ANY(@ids) GROUP BY meter_id"; + await using var command = Command(connection, sql); + command.Parameters.AddWithValue("ids", meterIds.ToArray()); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + result[reader.GetInt32(0)] = reader.GetFieldValue(1); + } + + return result; + } + + private static RollupBucket ReadBucket(DbDataReader reader, DateOnly start) => new( + start, + (ConsumptionKind)reader.GetInt16(2), + reader.GetDouble(3), + reader.GetDouble(4), + reader.GetDouble(5), + reader.GetDouble(6), + reader.GetDouble(7), + reader.GetInt32(8), + (RollupFlags)reader.GetInt32(9), + reader.GetFieldValue(10)); + + /// Each leaf's requested keys as contiguous ranges [from, to), flattened into parallel arrays. + private static (int[] Ids, DateOnly[] Froms, DateOnly[] Tos) Ranges( + IEnumerable leaves, Func> keys, Func next) + { + var ids = new List(); + var froms = new List(); + var tos = new List(); + foreach (var leaf in leaves) + { + DateOnly? start = null; + DateOnly end = default; + foreach (var key in keys(leaf).Order()) + { + if (start is not null && key == end) + { + end = next(key); + continue; + } + + if (start is { } s) + { + ids.Add(leaf.Id); + froms.Add(s); + tos.Add(end); + } + + start = key; + end = next(key); + } + + if (start is { } last) + { + ids.Add(leaf.Id); + froms.Add(last); + tos.Add(end); + } + } + + return ([.. ids], [.. froms], [.. tos]); + } + + private static NpgsqlCommand Command(DbConnection connection, string sql) => + new(sql, (NpgsqlConnection)connection); +} diff --git a/src/Infrastructure/Analysis/AnalysisReader.cs b/src/Infrastructure/Analysis/AnalysisReader.cs new file mode 100644 index 0000000..909a5e2 --- /dev/null +++ b/src/Infrastructure/Analysis/AnalysisReader.cs @@ -0,0 +1,184 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Infrastructure.Options; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace MeterVault.Infrastructure.Analysis; + +/// +/// The shared analysis reader (D-15, D-19, D-27, brief §5.3): quantities of physical and virtual meters and of +/// per-type totals, for one resolved period, in one bucket plan, with an optional comparison — every page, the API +/// and the export read through it, so they agree. +/// +/// +/// +/// A request loads the meter catalog once, expands virtual dependencies in memory, and then reads each table once +/// for all physical meters involved: coverage runs, month rollups (month and year buckets), day rollups (day and week +/// buckets, the edges of a month range, and every day a virtual meter reads), and the few partial edge days straight +/// from consumption. The point and series limits are checked before any SQL runs. It never reads the clock: +/// "now" is the period's (D-01). +/// +/// +/// Nothing is cached across requests. Every recompute rewrites the rollups in the same transaction as the +/// consumption rows (D-12), so an import, a correction, a swap or a definition edit is visible to the next read. +/// +/// +public sealed class AnalysisReader(IDbContextFactory contextFactory, IOptions? options = null) +{ + private readonly IDbContextFactory _contextFactory = contextFactory; + + /// + /// The zone local days and months are cut in: the configured one, or UTC without options — the same resolution + /// the normalizer uses, so a rollup built by it reads as current here. + /// + public TimeZoneInfo Zone { get; } = InstanceTimeZone.Resolve(options?.Value.TimeZone); + + /// Reads a scope over a period. + /// The period was resolved in another zone than the reader's. + public async Task ReadAsync(AnalysisRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + CheckZone(request.Period.Zone); + + // The limits come first, before any SQL (D-15). + if (Refusal(request) is { } refused) + { + return refused; + } + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var catalog = await AnalysisCatalog.LoadAsync(db, Zone, cancellationToken).ConfigureAwait(false); + return await ReadAsync(db, catalog, request, cancellationToken).ConfigureAwait(false); + } + + /// + /// Reads a request on a context and catalog the caller already holds — the cost reader, which loads the catalog once + /// for its bill and reads its quantities here. + /// + internal async Task ReadAsync( + MeterVaultDbContext db, AnalysisCatalog catalog, AnalysisRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(request); + CheckZone(request.Period.Zone); + + if (Refusal(request) is { } refused) + { + return refused; + } + + var plan = request.Plan; + if (plan is null && request.Bucket != BucketSize.Auto) + { + plan = BucketPlanner.Plan(request.Period, request.Bucket, maxPoints: request.MaxPoints); + } + + await db.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var run = new AnalysisRun(db, catalog, request, Zone); + return await run.ExecuteAsync(plan, cancellationToken).ConfigureAwait(false); + } + + /// + /// The buckets a request would be read in (D-05) — for from the coverage of the data it + /// reads, which is all this loads — so a caller that prices the buckets can cut them before reading (D-36). + /// + internal async Task PlanAsync( + MeterVaultDbContext db, AnalysisCatalog catalog, AnalysisRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(request); + CheckZone(request.Period.Zone); + + if (request.Plan is { } given) + { + return given; + } + + if (request.Bucket != BucketSize.Auto) + { + return BucketPlanner.Plan(request.Period, request.Bucket, maxPoints: request.MaxPoints); + } + + await db.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + return await new AnalysisRun(db, catalog, request, Zone).PlanAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// What a scope has data for as of (D-19) — for the all preset, which spans it, and + /// for "go to latest data" — without reading any rollups. + /// + public async Task GetAvailabilityAsync(AnalysisScope scope, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scope); + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var catalog = await AnalysisCatalog.LoadAsync(db, Zone, cancellationToken).ConfigureAwait(false); + await db.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + + var period = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, now, Zone); + var run = new AnalysisRun(db, catalog, new AnalysisRequest(scope, period), Zone); + return await run.AvailabilityOnlyAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// The data range of together as of (D-19) — physical coverage, a + /// virtual meter's joint coverage — on a context and catalog the caller holds; null when none has data. + /// + internal async Task QuantityAvailabilityAsync( + MeterVaultDbContext db, AnalysisCatalog catalog, IReadOnlyList meterIds, DateTimeOffset now, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(meterIds); + if (meterIds.Count == 0) + { + return null; + } + + await db.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var period = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, now, Zone); + var request = new AnalysisRequest(AnalysisScope.ForMeters(meterIds), period) { MaxSeries = int.MaxValue, QuantitiesOnly = true }; + var availability = await new AnalysisRun(db, catalog, request, Zone).AvailabilityOnlyAsync(cancellationToken).ConfigureAwait(false); + return availability.Quantity; + } + + /// Loads the catalog as a request sees it (definitions validated, totals classified). + public async Task LoadCatalogAsync(CancellationToken cancellationToken = default) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + return await AnalysisCatalog.LoadAsync(db, Zone, cancellationToken).ConfigureAwait(false); + } + + private void CheckZone(TimeZoneInfo zone) + { + if (!string.Equals(zone.Id, Zone.Id, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"The period was resolved in '{zone.Id}', but analysis data is read in '{Zone.Id}'; resolve periods in the instance zone.", + nameof(zone)); + } + } + + /// The refusal of a request that breaks the series or point limit (D-15), decided before any SQL; null otherwise. + private static AnalysisResult? Refusal(AnalysisRequest request) + { + if (request.Scope.Kind == AnalysisScopeKind.Meters && request.Scope.MeterIds.Count > request.MaxSeries) + { + return Refused(request, EmptyPlan(request), AnalysisRefusal.TooManySeries); + } + + var plan = request.Plan + ?? (request.Bucket != BucketSize.Auto ? BucketPlanner.Plan(request.Period, request.Bucket, maxPoints: request.MaxPoints) : null); + return plan is { Refused: true } ? Refused(request, plan, AnalysisRefusal.TooManyPoints) : null; + } + + private static BucketPlan EmptyPlan(AnalysisRequest request) => + new(request.Bucket, request.Bucket == BucketSize.Auto ? BucketSize.Day : request.Bucket, [], 0, Refused: false, Suggested: null); + + private static AnalysisResult Refused(AnalysisRequest request, BucketPlan plan, AnalysisRefusal refusal) => + new(request, plan, [], [], ScopeAvailability.None, []) { Refusal = refusal }; +} diff --git a/src/Infrastructure/Analysis/AnalysisRun.cs b/src/Infrastructure/Analysis/AnalysisRun.cs new file mode 100644 index 0000000..6ab1110 --- /dev/null +++ b/src/Infrastructure/Analysis/AnalysisRun.cs @@ -0,0 +1,1285 @@ +using System.Data.Common; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Ingestion; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Analysis; + +/// +/// One analysis request from catalog to result: which series and meters it needs, what to read for them, and how the +/// read data becomes bucket values (D-14, D-27), totals (D-22), comparisons (D-07) and availability (D-19). +/// +/// +/// The order matters and is fixed: targets → physical leaves → coverage → bucket plan and comparison → matched +/// coverage → every window a sum will need is registered → the rollups and edge rows are loaded, once per table → +/// values are computed from memory. Registering before loading is what keeps the reader to one query per table while +/// still summing arbitrary ranges (a matched range can end mid-day). +/// +internal sealed class AnalysisRun +{ + private static readonly BucketValue PendingValue = new(null, BucketStatus.Pending, Provenance.None, ValueIssue.AnalysisPending); + + private readonly MeterVaultDbContext _db; + private readonly DbConnection _connection; + private readonly AnalysisCatalog _catalog; + private readonly AnalysisRequest _request; + private readonly ResolvedPeriod _period; + private readonly TimeZoneInfo _zone; + + /// Every physical meter whose coverage is read (availability needs the whole scope). + private readonly Dictionary _leaves = []; + + /// The physical meters whose rollups are read (series, members and virtual sources). + private readonly HashSet _dataLeaves = []; + + private readonly Dictionary> _leavesOf = []; + private readonly Dictionary _freshness = []; + private readonly Dictionary _afterNow = []; + private readonly List _problems = []; + + private List _seriesIds = []; + private List<(int EnergyTypeId, MeasureGroup Group)> _measures = []; + private Side _current = null!; + private Side? _comparison; + private ComparisonResolution? _resolution; + private ResolvedPeriod? _comparisonPeriod; + private IReadOnlyList _pairs = []; + private readonly Dictionary _matched = []; + private bool _readsToday; + + public AnalysisRun(MeterVaultDbContext db, AnalysisCatalog catalog, AnalysisRequest request, TimeZoneInfo zone) + { + _db = db; + _connection = db.Database.GetDbConnection(); + _catalog = catalog; + _request = request; + _period = request.Period; + _zone = zone; + } + + private DateTimeOffset Now => _period.Now; + + /// The physical meters this run read, with what it registered and loaded for each (for tests of the D-15 budget). + internal IReadOnlyDictionary Leaves => _leaves; + + public async Task ExecuteAsync(BucketPlan? plan, CancellationToken cancellationToken) + { + ChooseTargets(); + CollectLeaves(includeScope: true); + await LoadCoverageAsync(cancellationToken).ConfigureAwait(false); + + plan ??= BucketPlanner.Plan(_period, BucketSize.Auto, CoarsestNeeded(), _request.MaxPoints); + if (plan.Refused) + { + return new AnalysisResult(_request, plan, [], [], ScopeAvailability.None, []) { Refusal = AnalysisRefusal.TooManyPoints }; + } + + foreach (var id in _dataLeaves) + { + _leaves[id].UseMonths = plan.Size is BucketSize.Month or BucketSize.Year && !_leaves[id].IsVirtualSource; + } + + _current = new Side(plan.Buckets, PeriodBucket.Of(_period), Now); + await ResolveComparisonAsync(plan, cancellationToken).ConfigureAwait(false); + MatchCoverage(); + RegisterWindows(); + await LoadDataAsync(cancellationToken).ConfigureAwait(false); + if (!_request.QuantitiesOnly) + { + await LoadFreshnessAsync(cancellationToken).ConfigureAwait(false); + } + + foreach (var id in _dataLeaves) + { + if (AfterNowOf(_leaves[id]) is { } block) + { + _afterNow[id] = block; + } + } + + var series = _seriesIds.Select(MeterSeries).ToList(); + var measures = _measures.Select(m => MeasureSeries(m.EnergyTypeId, m.Group)).ToList(); + var availability = await AvailabilityAsync(cancellationToken).ConfigureAwait(false); + CollectProblems(); + + return new AnalysisResult(_request, plan, series, measures, availability, Deduplicated(_problems)) + { + Comparison = _resolution is null ? null : new AnalysisComparison(_resolution, _comparisonPeriod, _pairs), + Classification = Classification(), + }; + } + + /// + /// The buckets chooses for the request (D-05) from the coverage of the data it reads — + /// the plan would make, without reading any rollups. + /// + public async Task PlanAsync(CancellationToken cancellationToken) + { + ChooseTargets(); + CollectLeaves(includeScope: false); + await LoadCoverageAsync(cancellationToken).ConfigureAwait(false); + return BucketPlanner.Plan(_period, BucketSize.Auto, CoarsestNeeded(), _request.MaxPoints); + } + + public async Task AvailabilityOnlyAsync(CancellationToken cancellationToken) + { + CollectLeaves(includeScope: true); + await LoadCoverageAsync(cancellationToken).ConfigureAwait(false); + return await AvailabilityAsync(cancellationToken).ConfigureAwait(false); + } + + // ---------------------------------------------------------------- targets and leaves + + /// The meter series and measure totals the scope asks for. + private void ChooseTargets() + { + var scope = _request.Scope; + switch (scope.Kind) + { + case AnalysisScopeKind.Meters: + foreach (var id in scope.MeterIds) + { + if (_catalog.Find(id) is null) + { + _problems.Add(new AnalysisProblem(AnalysisProblemKind.UnknownMeter, id)); + } + else + { + _seriesIds.Add(id); + } + } + + break; + + case AnalysisScopeKind.EnergyType: + var type = scope.EnergyTypeId!.Value; + _measures = [.. _catalog.Totals.ForType(type).Measures.Select(g => (type, g))]; + if (_request.IncludeMeterSeries) + { + _seriesIds = [.. ScopeMeters().OrderBy(id => id)]; + } + + break; + + default: + _measures = [.. _catalog.Totals.Types.OrderBy(t => t.Key).SelectMany(t => t.Value.Measures.Select(g => (t.Key, g)))]; + if (_request.IncludeMeterSeries) + { + _seriesIds = [.. ScopeMeters().OrderBy(id => id)]; + } + + break; + } + } + + /// The meters of the scope: the selection, the energy type's meters, or every meter. + private IEnumerable ScopeMeters() => _request.Scope.Kind switch + { + AnalysisScopeKind.Meters => _request.Scope.MeterIds.Where(id => _catalog.Find(id) is not null), + AnalysisScopeKind.EnergyType => _catalog.Meters.Values.Where(m => m.EnergyTypeId == _request.Scope.EnergyTypeId).Select(m => m.Id), + _ => _catalog.Meters.Keys, + }; + + /// The meters whose cost the scope is about (D-19): the selection, or the billed meters of the type(s). + private IEnumerable CostMeters() => _request.Scope.Kind switch + { + AnalysisScopeKind.Meters => ScopeMeters(), + AnalysisScopeKind.EnergyType => _catalog.Totals.ForType(_request.Scope.EnergyTypeId!.Value).Billing.Items, + _ => _catalog.Totals.BillItems, + }; + + /// + /// Expands the targets to the physical meters they read, through evaluable virtual definitions only. Data leaves + /// feed values; the scope's and the bill's other meters only need their coverage, for availability. + /// + private void CollectLeaves(bool includeScope) + { + var dataTargets = _seriesIds.Concat(_measures.SelectMany(m => m.Group.MeterIds)).Distinct().ToList(); + foreach (var id in dataTargets) + { + foreach (var leaf in LeavesOf(id)) + { + _dataLeaves.Add(leaf); + Leaf(leaf); + } + + MarkVirtualSources(id, []); + } + + if (!includeScope) + { + return; + } + + foreach (var id in ScopeMeters().Concat(CostMeters()).Distinct()) + { + foreach (var leaf in LeavesOf(id)) + { + Leaf(leaf); + } + } + } + + /// The physical meters a meter reads: itself, or the leaves of its evaluable formula; none when not evaluable. + private IReadOnlyList LeavesOf(int meterId) + { + if (_leavesOf.TryGetValue(meterId, out var cached)) + { + return cached; + } + + var leaves = new SortedSet(); + var seen = new HashSet(); + var pending = new Stack([meterId]); + while (pending.Count > 0) + { + var id = pending.Pop(); + if (!seen.Add(id) || _catalog.Find(id) is not { } meter) + { + continue; + } + + if (!meter.IsVirtual) + { + leaves.Add(id); + continue; + } + + foreach (var source in meter.Formula?.MeterIds ?? []) + { + pending.Push(source); + } + } + + IReadOnlyList result = [.. leaves]; + _leavesOf[meterId] = result; + return result; + } + + /// Marks the physical meters a virtual meter reads directly or through nested ones: they are read day by day. + private void MarkVirtualSources(int meterId, HashSet seen) + { + if (!seen.Add(meterId) || _catalog.Find(meterId) is not { IsVirtual: true, Formula: { } formula }) + { + return; + } + + foreach (var source in formula.MeterIds) + { + if (_catalog.Find(source) is { IsVirtual: false }) + { + Leaf(source).IsVirtualSource = true; + } + else + { + MarkVirtualSources(source, seen); + } + } + } + + private LeafData Leaf(int meterId) + { + if (!_leaves.TryGetValue(meterId, out var leaf)) + { + _leaves[meterId] = leaf = new LeafData(_catalog.Meters[meterId], _zone); + } + + return leaf; + } + + private async Task LoadCoverageAsync(CancellationToken cancellationToken) + { + var runs = await AnalysisQueries.CoverageAsync(_connection, _leaves.Keys, cancellationToken).ConfigureAwait(false); + foreach (var (id, leaf) in _leaves) + { + leaf.Runs = runs.GetValueOrDefault(id) ?? []; + } + } + + /// + /// The coarsest resolution among the data covering the period (D-05): auto never plans finer. A run divided at + /// month boundaries resolves months whatever its class. + /// + private ResolutionClass? CoarsestNeeded() + { + ResolutionClass? coarsest = null; + foreach (var id in _dataLeaves) + { + foreach (var run in _leaves[id].CappedRuns(Now)) + { + if (run.IsGap || run.From >= _period.To || run.To <= _period.From) + { + continue; + } + + var resolution = run.DividedAtMonths && run.Resolution > ResolutionClass.Month ? ResolutionClass.Month : run.Resolution; + coarsest = coarsest is { } c && c >= resolution ? c : resolution; + } + } + + return coarsest; + } + + // ---------------------------------------------------------------- comparison and matched coverage + + private async Task ResolveComparisonAsync(BucketPlan plan, CancellationToken cancellationToken) + { + if (_request.Comparison.Kind == ComparisonKind.None) + { + return; + } + + _resolution = ComparisonResolver.Resolve(_period, _request.Comparison); + if (!_resolution.IsApplicable) + { + return; + } + + var comparison = _resolution.Period; + _comparisonPeriod = comparison.ToResolvedPeriod(_period); + _pairs = ComparisonResolver.PairBuckets(_period, comparison, plan.Buckets); + _comparison = new Side([.. _pairs.Select(p => p.Comparison)], PeriodBucket.Of(_comparisonPeriod), _comparisonPeriod.Now); + + var stamps = await AnalysisQueries.OpeningBalancesAsync(_connection, _dataLeaves, cancellationToken).ConfigureAwait(false); + foreach (var (id, stamp) in stamps) + { + _leaves[id].OpeningBalanceStamp = stamp; + } + } + + /// The coverage both periods share (D-07), per series: the meter's own, or all its sources jointly. + private void MatchCoverage() + { + if (_comparison is null || _resolution?.Period is not { } comparison) + { + return; + } + + foreach (var id in _seriesIds) + { + var meter = _catalog.Meters[id]; + var leaves = LeavesOf(id).Select(l => _leaves[l]).ToList(); + var own = !meter.IsVirtual && !_request.AsContributors; + _matched[MeterKey(id)] = Match(comparison, leaves, own); + } + + foreach (var (type, group) in _measures) + { + var leaves = group.MeterIds.SelectMany(LeavesOf).Distinct().Select(l => _leaves[l]).ToList(); + _matched[MeasureKey(type, group)] = Match(comparison, leaves, own: false); + } + } + + private MatchedCoverageResult Match(ComparisonPeriod comparison, List leaves, bool own) + { + if (leaves.Count == 0 || leaves.Exists(l => l.Meter.IsPending)) + { + return MatchedCoverageResult.NotComparable; + } + + IReadOnlyList> sources = [.. leaves.Select(l => own ? l.Runs : l.ServiceRuns)]; + IReadOnlyList balances = [.. leaves.Where(l => l.OpeningBalanceStamp is not null).Select(l => l.OpeningBalanceStamp!.Value)]; + var current = MatchSide.OfSources(_period.From, _period.To, Now, _zone, sources, balances); + var previous = MatchSide.OfSources(comparison.From, comparison.To, _comparison!.Cutoff, _zone, sources, balances); + return MatchedCoverage.Match(current, previous, instant => comparison.MapInstant(instant, _zone)); + } + + // ---------------------------------------------------------------- registering and loading + + /// Registers every window a sum will read, so the loads fetch exactly those rows. + private void RegisterWindows() + { + List sides = [_current]; + if (_comparison is not null) + { + sides.Add(_comparison); + } + + foreach (var id in _dataLeaves) + { + var leaf = _leaves[id]; + foreach (var side in sides) + { + foreach (var bucket in side.Buckets) + { + leaf.Request(bucket.From, bucket.To); + } + + if (leaf.IsVirtualSource) + { + foreach (var window in side.DayWindows(_zone)) + { + leaf.Request(window.From, window.To); + } + } + } + } + + // Matched pieces are only summed: their partial days are summed in the database, not loaded (D-15). + foreach (var (key, matched) in _matched) + { + foreach (var leaf in LeavesOfKey(key)) + { + foreach (var piece in matched.Pieces) + { + leaf.RequestSummed(piece.Current.From, piece.Current.To, _current.Cutoff); + leaf.RequestSummed(piece.Comparison.From, piece.Comparison.To, _comparison!.Cutoff); + } + } + } + + // Rows of today that close after now go to the "recorded after now" block (D-04). + var today = RangeParts.LocalDate(Now, _zone); + _readsToday = ReachesNow() && _period.FirstDay <= today && (_period.Preset == PeriodPreset.AllHistory || _period.NominalLastDay() >= today); + if (_readsToday) + { + foreach (var id in _dataLeaves) + { + _leaves[id].RequestRawDay(today); + } + } + } + + private IEnumerable LeavesOfKey(string key) + { + var ids = key.StartsWith('m') + ? LeavesOf(_seriesIds.First(id => MeterKey(id) == key)) + : _measures.Where(m => MeasureKey(m.EnergyTypeId, m.Group) == key).SelectMany(m => m.Group.MeterIds).SelectMany(LeavesOf).Distinct(); + return ids.Select(id => _leaves[id]); + } + + /// True when the requested range reaches now or lies after it: then rows may close after now (D-04). + private bool ReachesNow() => _period.IsToDate || _period.ExtendsPastNow || _period.HasNotStarted(); + + private async Task LoadDataAsync(CancellationToken cancellationToken) + { + var data = _dataLeaves.ToDictionary(id => id, id => _leaves[id]); + var today = RangeParts.LocalDate(Now, _zone); + var afterFrom = _period.FirstDay > today ? _period.FirstDay : today.AddDays(1); + DateOnly? afterTo = _period.Preset == PeriodPreset.AllHistory ? null : _period.NominalLastDay().AddDays(1); + IReadOnlyCollection afterIds = ReachesNow() ? _dataLeaves : []; + + await AnalysisQueries.DayRollupsAsync(_connection, data, afterIds, afterFrom, afterTo, Now, cancellationToken).ConfigureAwait(false); + await AnalysisQueries.MonthRollupsAsync(_connection, data, cancellationToken).ConfigureAwait(false); + await AnalysisQueries.RawDaysAsync(_connection, data, _zone, cancellationToken).ConfigureAwait(false); + await AnalysisQueries.WindowSumsAsync(_connection, data, cancellationToken).ConfigureAwait(false); + foreach (var leaf in data.Values) + { + leaf.Seal(); + } + } + + /// Freshness of every data leaf (D-18): recent reading times, the last event, and the live sources. + private async Task LoadFreshnessAsync(CancellationToken cancellationToken) + { + var ids = _dataLeaves.ToList(); + if (ids.Count == 0) + { + return; + } + + var readings = await AnalysisQueries.RecentReadingsAsync(_connection, ids, cancellationToken).ConfigureAwait(false); + var events = await AnalysisQueries.LastEventsAsync(_connection, ids, cancellationToken).ConfigureAwait(false); + var sources = await _db.MeterSources.AsNoTracking() + .Include(s => s.Endpoint) + .Where(s => ids.Contains(s.MeterId) && s.IsEnabled) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + foreach (var id in ids) + { + var times = readings.GetValueOrDefault(id) ?? []; + var live = sources.Where(s => s.MeterId == id && s.SourceType is SourceType.Mqtt or SourceType.Tasmota or SourceType.HomeAssistant).ToList(); + TimeSpan? poll = null; + foreach (var source in live.Where(s => s.SourceType == SourceType.HomeAssistant)) + { + if (source.Endpoint is { } endpoint && HaEndpointConfig.Parse(endpoint.Config).UseWebSocket) + { + continue; + } + + var interval = TimeSpan.FromMinutes(Math.Max(1, SourceConfig.Parse(source.Config).PollMinutes ?? 60)); + poll = poll is { } p && p >= interval ? p : interval; + } + + var input = new FreshnessInput( + times.Count > 0 ? times.Max() : null, + events.TryGetValue(id, out var lastEvent) ? lastEvent : null, + times, + live.Count > 0, + poll); + _freshness[id] = FreshnessRules.Evaluate(input, Now); + } + } + + // ---------------------------------------------------------------- physical values + + /// A physical meter's sums per bucket of one side, and their total. + private (Tally[] Buckets, Tally Total) Tallies(Side side, LeafData leaf) + { + if (side.Tallies.TryGetValue(leaf.Id, out var cached)) + { + return cached; + } + + var tallies = side.Buckets.Select(b => leaf.Sum(b.From, b.To, side.Cutoff)).ToArray(); + var total = new Tally(); + foreach (var tally in tallies) + { + total.Add(tally); + } + + side.Tallies[leaf.Id] = (tallies, total); + return (tallies, total); + } + + /// + /// A physical meter's values on one side: per bucket the sum with the status its coverage gives it (D-14), and the + /// period total with the period's own status. As a contributor () the time outside its + /// service period is a known zero (D-24). + /// + private PhysicalValues Physical(Side side, LeafData leaf, bool service) + { + var key = (leaf.Id, service); + if (side.Physical.TryGetValue(key, out var cached)) + { + return cached; + } + + var (tallies, total) = Tallies(side, leaf); + PhysicalValues result; + if (leaf.Meter.IsPending) + { + result = new PhysicalValues([.. side.Buckets.Select(_ => PendingValue)], PendingValue, null, null); + } + else + { + var runs = service ? leaf.ServiceRuns : leaf.Runs; + var implied = leaf.Meter.Quantity.ImpliedProvenance; + var coverage = CoverageEvaluator.EvaluateSeries(side.Buckets, runs, _zone, [.. tallies.Select(t => t.OpeningBalance)], side.Cutoff); + var values = coverage.Select((c, i) => Withheld(c.ToValue(tallies[i].Amount, tallies[i].ProvenanceWith(implied)), tallies[i])).ToList(); + var totalCoverage = CoverageEvaluator.Evaluate(side.PeriodBucket, runs, _zone, total.OpeningBalance, side.Cutoff); + var totalValue = side == _current && _period.HasNotStarted() + ? BucketValue.Missing(ValueIssue.NotYetOccurred) + : Withheld(totalCoverage.ToValue(total.Amount, total.ProvenanceWith(implied)), total); + result = new PhysicalValues(values, totalValue, coverage, totalCoverage); + } + + side.Physical[key] = result; + return result; + } + + /// + /// A bucket whose sum left out rows closing after the cutoff (A-05) is not complete, even where coverage reaches its + /// end: a whole rollup day or month is withheld once one of its rows closes after the cutoff — a current-month label + /// beside live readings takes the day's live share with it — so an available status would pass what is shown off + /// as the whole bucket, or an empty day as a true zero (D-14, A-20). + /// + private static BucketValue Withheld(BucketValue value, Tally tally) => + value.Status == BucketStatus.Available && tally.AfterRows > 0 + ? value with { Status = BucketStatus.Partial, Issue = ValueIssue.RecordedAfterNow } + : value; + + // ---------------------------------------------------------------- virtual values + + /// A virtual meter evaluated on one side (D-27); null when its definition cannot be evaluated. + private VirtualEvaluation? Evaluate(Side side, int meterId) + { + if (side.Evaluations.TryGetValue(meterId, out var cached)) + { + return cached; + } + + VirtualEvaluation? evaluation = null; + if (_catalog.Find(meterId) is { IsVirtual: true, Formula: { } formula } meter) + { + var sources = formula.MeterIds.Select(id => SourceFor(side, id)).OfType().ToList(); + var kind = meter.Validation?.Kind ?? meter.Quantity.Kind; + evaluation = VirtualEvaluator.Evaluate(meterId, formula, kind, side.Buckets, sources); + } + + side.Evaluations[meterId] = evaluation; + return evaluation; + } + + /// One source of a formula as the evaluator reads it, with the lifecycle of the source (D-24). + private VirtualSource? SourceFor(Side side, int sourceId) + { + if (_catalog.Find(sourceId) is not { } source) + { + return null; + } + + VirtualSource input; + if (source.IsVirtual) + { + input = Evaluate(side, sourceId) is { } nested + ? VirtualSource.FromEvaluation(nested) + : _catalog.Graph.CycleFor(sourceId) is { } cycle + ? VirtualSource.Failed(sourceId, BucketStatus.Invalid, ValueIssue.DependencyCycle, cycle) + : VirtualSource.Failed(sourceId, BucketStatus.Invalid, ValueIssue.InvalidDefinition, [sourceId]); + } + else + { + input = PhysicalSource(side, _leaves[sourceId]); + } + + return input with { InstalledAt = source.Meter.InstalledAt, RetiredAt = source.Meter.RetiredAt }; + } + + /// + /// A physical source for the evaluator (A-12): its figures per local day — covered or not from the coverage + /// evaluator, the resolution and month division of the run covering the day, the day's actual amount — and its + /// own bucket and period states, as a contributor. + /// + private VirtualSource PhysicalSource(Side side, LeafData leaf) + { + if (side.Sources.TryGetValue(leaf.Id, out var cached)) + { + return cached; + } + + VirtualSource source; + if (leaf.Meter.IsPending) + { + source = VirtualSource.Failed(leaf.Id, BucketStatus.Pending, ValueIssue.AnalysisPending, [leaf.Id]); + } + else + { + var windows = side.DayWindows(_zone); + var dayBuckets = windows.Select(w => new AnalysisBucket(w.Day, w.Day.AddDays(1), w.From, w.To, BucketSize.Day)).ToList(); + var coverage = CoverageEvaluator.EvaluateSeries(dayBuckets, leaf.Runs, _zone, null, side.Cutoff); + var capped = leaf.CappedRuns(side.Cutoff).Where(r => !r.IsGap).OrderBy(r => r.From).ToList(); + var implied = leaf.Meter.Quantity.ImpliedProvenance; + + var days = new Dictionary(); + var cursor = 0; + for (var i = 0; i < windows.Count; i++) + { + if (coverage[i].Covered <= TimeSpan.Zero) + { + continue; + } + + var window = windows[i]; + while (cursor < capped.Count && capped[cursor].To <= window.From) + { + cursor++; + } + + var divided = true; + var any = false; + for (var r = cursor; r < capped.Count && capped[r].From < window.To; r++) + { + if (capped[r].To > window.From) + { + any = true; + divided &= capped[r].DividedAtMonths; + } + } + + var tally = leaf.Sum(window.From, window.To, side.Cutoff); + days[window.Day] = new SourceDay( + tally.Amount, true, coverage[i].Resolution ?? ResolutionClass.Hour, tally.ProvenanceWith(implied), any && divided); + } + + var states = Physical(side, leaf, service: true); + source = new VirtualSource(leaf.Id, days) { BucketStates = states.Values, PeriodState = states.Total }; + } + + side.Sources[leaf.Id] = source; + return source; + } + + // ---------------------------------------------------------------- series + + private AnalysisSeries MeterSeries(int meterId) + { + var meter = _catalog.Meters[meterId]; + var key = SeriesKey.ForMeter(meterId, meter.EnergyTypeId, meter.Quantity.Unit); + var entry = _catalog.Totals.Meters.GetValueOrDefault(meterId); + + if (!meter.IsVirtual) + { + var leaf = _leaves[meterId]; + var asContributor = _request.AsContributors; + var values = Physical(_current, leaf, service: asContributor); + return new AnalysisSeries(key, meter.Name, SeriesBasis.Physical, meter.Quantity.Kind, meter.Quantity.Unit, values.Values, values.Total, true) + { + Coverage = values.Coverage, + Resolution = values.TotalCoverage?.Resolution, + Availability = AvailableRange.OfRuns(leaf.Runs, Now, _zone), + Freshness = _freshness.GetValueOrDefault(meterId) ?? Freshness.None, + RecordedAfterNow = AfterNowFor([meterId]), + Totals = entry, + Notes = meter.Quantity.Notes, + Comparison = Compare(MeterKey(meterId), side => { var v = Physical(side, leaf, service: asContributor); return (v.Values, v.Total); }, side => MatchedValue(side, meterId)), + }; + } + + var info = VirtualInfo(meter); + var leaves = LeavesOf(meterId); + var basis = meter.VirtualStatus == VirtualMeterStatus.Legacy ? SeriesBasis.LegacyVirtual : SeriesBasis.Virtual; + var evaluation = Evaluate(_current, meterId); + var series = evaluation is null + ? new AnalysisSeries(key, meter.Name, basis, meter.Quantity.Kind, meter.Quantity.Unit, Invalid(meter, _current.Buckets.Count), InvalidValue(meter), false) + : new AnalysisSeries( + key, meter.Name, basis, meter.Quantity.Kind, meter.Quantity.Unit, + MarkLegacy(meter, evaluation.Values), MarkLegacy(meter, evaluation.Total), evaluation.IsAdditive) + { + Resolution = evaluation.Resolution, + Contributions = Contributions(_current, evaluation, [meterId]), + }; + + return series with + { + Availability = VirtualAvailability(meterId), + Freshness = FreshnessRules.Combine(leaves.Select(l => _freshness.GetValueOrDefault(l) ?? Freshness.None)), + RecordedAfterNow = AfterNowFor(leaves), + Totals = entry, + Virtual = info, + Notes = meter.Quantity.Notes, + Comparison = Compare(MeterKey(meterId), side => VirtualValues(side, meter), side => MatchedValue(side, meterId)), + }; + } + + private AnalysisSeries MeasureSeries(int energyTypeId, MeasureGroup group) + { + var key = SeriesKey.ForMeasure(energyTypeId, group.Measure, group.Unit); + var (values, total) = MeasureValuesOf(_current, group.MeterIds); + var leaves = group.MeterIds.SelectMany(LeavesOf).Distinct().ToList(); + return new AnalysisSeries(key, string.Empty, SeriesBasis.Measure, KindOf(group.Measure), group.Unit, values, total, true) + { + MemberIds = group.MeterIds, + Resolution = MeasureResolution(group.MeterIds), + Availability = AvailableRange.Union(group.MeterIds.Select(MeterAvailability), _zone), + Freshness = FreshnessRules.Combine(leaves.Select(l => _freshness.GetValueOrDefault(l) ?? Freshness.None)), + RecordedAfterNow = AfterNowFor(leaves), + Comparison = Compare( + MeasureKey(energyTypeId, group), + side => MeasureValuesOf(side, group.MeterIds), + side => group.MeterIds.Select(id => MatchedValue(side, id)).Aggregate((double?)0d, (sum, v) => sum is { } s && v is { } x ? s + x : null)), + }; + } + + /// + /// The coarsest resolution among a measure's members (D-51): each as its own series reports it — a physical meter's + /// coverage over the period, a virtual meter's evaluation (divided runs are at most monthly, A-03). A total can be + /// opened no finer than its coarsest member resolves. + /// + private ResolutionClass? MeasureResolution(IReadOnlyList members) + { + ResolutionClass? coarsest = null; + foreach (var id in members) + { + var resolution = _catalog.Meters[id].IsVirtual + ? Evaluate(_current, id)?.Resolution + : Physical(_current, _leaves[id], service: true).TotalCoverage?.Resolution; + if (resolution is { } value && (coarsest is null || value > coarsest)) + { + coarsest = value; + } + } + + return coarsest; + } + + /// A measure's values on one side: its members' values as contributors, added up (D-22). + private (IReadOnlyList Values, BucketValue Total) MeasureValuesOf(Side side, IReadOnlyList members) + { + var parts = members.Select(id => (id, Values: MemberValues(side, id))).ToList(); + var values = MeasureValues.SumSeries([.. parts.Select(p => (p.id, p.Values.Values))], side.Buckets.Count); + var total = MeasureValues.Sum([.. parts.Select(p => (p.id, p.Values.Total))]); + if (side == _current && _period.HasNotStarted()) + { + total = BucketValue.Missing(ValueIssue.NotYetOccurred); + } + + return (values, total); + } + + private (IReadOnlyList Values, BucketValue Total) MemberValues(Side side, int meterId) + { + var meter = _catalog.Meters[meterId]; + if (!meter.IsVirtual) + { + var values = Physical(side, _leaves[meterId], service: true); + return (values.Values, values.Total); + } + + return VirtualValues(side, meter); + } + + private (IReadOnlyList Values, BucketValue Total) VirtualValues(Side side, AnalysisMeter meter) + { + var evaluation = Evaluate(side, meter.Id); + return evaluation is null + ? (Invalid(meter, side.Buckets.Count), InvalidValue(meter)) + : (MarkLegacy(meter, evaluation.Values), MarkLegacy(meter, evaluation.Total)); + } + + /// The series on the comparison side, and the change over the coverage both share (D-07, D-08). + private SeriesComparison? Compare( + string key, + Func Values, BucketValue Total)> valuesOn, + Func matchedOn) + { + if (_comparison is null) + { + return null; + } + + var (values, total) = valuesOn(_comparison); + var matched = _matched.GetValueOrDefault(key) ?? MatchedCoverageResult.NotComparable; + if (!matched.IsComparable) + { + return new SeriesComparison(values, total, matched, null, null, Change.Unavailable); + } + + var current = matchedOn(new Side(MatchedWindows(matched, comparison: false), _current.PeriodBucket, _current.Cutoff)); + var previous = matchedOn(new Side(MatchedWindows(matched, comparison: true), _comparison.PeriodBucket, _comparison.Cutoff)); + return new SeriesComparison(values, total, matched, current, previous, Change.Between(current, previous)); + } + + /// The matched pieces of one side as windows to sum. + private static List MatchedWindows(MatchedCoverageResult matched, bool comparison) => + [.. matched.Pieces.Select(p => comparison ? p.Comparison : p.Current).Select(r => new AnalysisBucket(r.FirstDay, r.LastDay.AddDays(1), r.From, r.To, BucketSize.Day))]; + + /// + /// A meter's value over a side's windows (here: matched pieces): a physical meter's actual sum, a virtual meter's + /// formula applied to its sources' sums — the joint coverage the matched range is (D-27). Null when not evaluable. + /// + private double? MatchedValue(Side windows, int meterId) + { + if (_catalog.Find(meterId) is not { } meter) + { + return null; + } + + if (!meter.IsVirtual) + { + var leaf = _leaves[meterId]; + if (leaf.Meter.IsPending) + { + return null; + } + + return windows.Buckets.Sum(w => leaf.Sum(w.From, w.To, windows.Cutoff).Amount); + } + + if (meter.Formula is not { } formula) + { + return null; + } + + var value = formula.Evaluate(id => MatchedValue(windows, id) ?? double.NaN); + return double.IsFinite(value) ? value : null; + } + + private List Contributions(Side side, VirtualEvaluation evaluation, IReadOnlyList path) => + [.. evaluation.Contributions.Select(c => + { + var source = _catalog.Find(c.MeterId); + var isVirtual = source?.IsVirtual == true; + IReadOnlyList sourcePath = [.. path, c.MeterId]; + var nested = isVirtual && !path.Contains(c.MeterId) && Evaluate(side, c.MeterId) is { } inner + ? Contributions(side, inner, sourcePath) + : []; + return new SeriesContribution( + c.MeterId, source?.Name ?? string.Empty, isVirtual, c.Coefficient, c.Values, c.UsedAmounts, c.Total, c.UsedTotal, sourcePath, nested); + })]; + + private VirtualSeriesInfo VirtualInfo(AnalysisMeter meter) + { + var definition = meter.Definition; + return new VirtualSeriesInfo( + meter.VirtualStatus ?? VirtualMeterStatus.NeedsConfiguration, + definition?.Expression, + meter.CostRule, + meter.Formula?.MeterIds ?? definition?.ReferencedMeterIds ?? [], + LeavesOf(meter.Id), + meter.Validation?.Problems ?? [], + meter.Legacy, + meter.VirtualStatus == VirtualMeterStatus.Malformed ? meter.StoredDefinition?.Problem : null); + } + + /// A virtual meter's own buckets when it cannot be evaluated: invalid, naming why (D-26, D-28). + private IReadOnlyList Invalid(AnalysisMeter meter, int count) + { + var value = InvalidValue(meter); + return [.. Enumerable.Repeat(value, count)]; + } + + private BucketValue InvalidValue(AnalysisMeter meter) + { + var id = meter.Id; + if (_catalog.Graph.CycleFor(id) is { } cycle) + { + return new BucketValue(null, BucketStatus.Invalid, Provenance.Derived, ValueIssue.DependencyCycle, null, cycle); + } + + var problem = meter.Validation?.Problems.FirstOrDefault(); + if (problem is { Kind: VirtualProblemKind.DependencyCycle }) + { + IReadOnlyList path = problem.MeterIds.Count > 0 && problem.MeterIds[0] == id ? problem.MeterIds : [id, .. problem.MeterIds]; + return new BucketValue(null, BucketStatus.Invalid, Provenance.Derived, ValueIssue.DependencyCycle, null, path); + } + + if (meter.Legacy is { Outcome: LegacyDerivationOutcome.Cycle } legacy) + { + IReadOnlyList path = legacy.MeterIds.Count > 0 && legacy.MeterIds[0] == id ? legacy.MeterIds : [id, .. legacy.MeterIds]; + return new BucketValue(null, BucketStatus.Invalid, Provenance.Derived, ValueIssue.DependencyCycle, null, path); + } + + IReadOnlyList cause = problem is { MeterIds.Count: > 0 } && problem.MeterIds[0] != id ? [id, problem.MeterIds[0]] : [id]; + return new BucketValue(null, BucketStatus.Invalid, Provenance.Derived, ValueIssue.InvalidDefinition, null, cause); + } + + /// A legacy meter's evaluated values carry "legacy — confirm" where nothing more important is said (D-28). + private static IReadOnlyList MarkLegacy(AnalysisMeter meter, IReadOnlyList values) => + meter.VirtualStatus == VirtualMeterStatus.Legacy ? [.. values.Select(v => MarkLegacy(meter, v))] : values; + + private static BucketValue MarkLegacy(AnalysisMeter meter, BucketValue value) => + meter.VirtualStatus == VirtualMeterStatus.Legacy && value.Issue == ValueIssue.None + ? value with { Issue = ValueIssue.LegacyDefinition } + : value; + + private static QuantityKind KindOf(TotalsMeasure measure) => measure switch + { + TotalsMeasure.Generation => QuantityKind.Generation, + TotalsMeasure.Export => QuantityKind.Export, + TotalsMeasure.Runtime => QuantityKind.Runtime, + _ => QuantityKind.Consumption, + }; + + // ---------------------------------------------------------------- recorded after now, availability, problems + + /// + /// A physical meter's rows in the requested range that close after now (D-04, A-05): rollups left out of the + /// current sums, today's rows after now, and the days after today up to the end of the named range. + /// + private RecordedAfterNow? AfterNowOf(LeafData leaf) + { + var (_, total) = Tallies(_current, leaf); + var block = new Tally(); + block.Add(total); + + var rows = block.AfterRows; + var amount = block.AfterAmount; + var first = block.AfterFirstDay; + var last = block.AfterLastDay; + + void Add(int count, double sum, DateOnly day) + { + if (count <= 0) + { + return; + } + + rows += count; + amount += sum; + first = first is { } f && f <= day ? f : day; + last = last is { } l && l >= day ? l : day; + } + + if (_readsToday) + { + var today = RangeParts.LocalDate(Now, _zone); + var later = leaf.RawFrom(today, Now).ToList(); + Add(later.Count, later.Sum(r => r.Amount), today); + } + + foreach (var (day, rollup) in leaf.AfterNowDays) + { + Add(rollup.Rows, rollup.Amount, day); + } + + return rows > 0 && first is { } firstDay && last is { } lastDay ? new RecordedAfterNow(leaf.Id, rows, amount, firstDay, lastDay) : null; + } + + private IReadOnlyList AfterNowFor(IEnumerable leaves) => + [.. leaves.Select(id => _afterNow.GetValueOrDefault(id)).OfType()]; + + /// A meter's data range (D-19): a physical meter's coverage, a virtual meter's joint coverage. + private AvailableRange? MeterAvailability(int meterId) + { + if (_catalog.Find(meterId) is not { } meter) + { + return null; + } + + return meter.IsVirtual + ? VirtualAvailability(meterId) + : _leaves.TryGetValue(meterId, out var leaf) ? AvailableRange.OfRuns(leaf.Runs, Now, _zone) : null; + } + + /// + /// Where every source of a virtual meter has data (D-27): the intersection of their coverage, with the time outside + /// a source's service period counted as its known zero (D-24), within the outer bounds of the sources' own data. + /// + private AvailableRange? VirtualAvailability(int meterId) + { + var leaves = LeavesOf(meterId).Where(_leaves.ContainsKey).Select(id => _leaves[id]).ToList(); + if (leaves.Count == 0) + { + return null; + } + + IReadOnlyList> capped = [.. leaves.Select(l => CoverageRuns.CapAt(l.ServiceRuns, Now, _zone))]; + var joint = CoverageRuns.Covered(CoverageRuns.Intersect(capped)); + var hull = AvailableRange.Union(leaves.Select(l => AvailableRange.OfRuns(l.Runs, Now, _zone)), _zone); + if (joint is not { First: { } from, Last: { } to } || hull is null) + { + return null; + } + + return AvailableRange.Of(from > hull.From ? from : hull.From, to < hull.To ? to : hull.To, _zone); + } + + /// The scope's availability (D-19): its meters' data, and for cost its billed meters' data plus manual costs. + private async Task AvailabilityAsync(CancellationToken cancellationToken) + { + var quantity = AvailableRange.Union(ScopeMeters().Select(MeterAvailability), _zone); + if (_request.QuantitiesOnly) + { + return new ScopeAvailability(quantity, null, null); + } + + var metered = AvailableRange.Union(CostMeters().Select(MeterAvailability), _zone); + + var today = RangeParts.LocalDate(Now, _zone); + var manual = await _db.ManualCosts.AsNoTracking() + .Where(c => c.PeriodStart <= today) + .Select(c => new { c.MeterId, c.PeriodStart }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + var scopeMeters = ScopeMeters().ToHashSet(); + var days = manual + .Where(c => _request.Scope.Kind == AnalysisScopeKind.Portfolio || (c.MeterId is { } id && scopeMeters.Contains(id))) + .Select(c => c.PeriodStart) + .ToList(); + + AvailableRange? manualRange = null; + if (days.Count > 0) + { + var from = Core.Normalization.GapAttribution.LocalMidnight(days.Min(), _zone); + var to = Core.Normalization.GapAttribution.LocalMidnight(days.Max().AddDays(1), _zone); + manualRange = AvailableRange.Of(from, to < Now ? to : Now, _zone) ?? AvailableRange.Of(from, to, _zone); + } + + var cost = AvailableRange.Union([metered, manualRange], _zone); + LatestPeriod? latest = null; + if (cost is not null) + { + var month = cost.LatestMonth; + var byMeters = metered?.LatestMonth == month; + var byManual = manualRange?.LatestMonth == month; + latest = new LatestPeriod(month, byMeters && byManual ? LatestPeriodBasis.Both : byManual ? LatestPeriodBasis.Manual : LatestPeriodBasis.Meters); + } + + return new ScopeAvailability(quantity, cost, latest); + } + + /// The attention items (D-53) of everything the result shows. + private void CollectProblems() + { + var shown = _seriesIds.Concat(_measures.SelectMany(m => m.Group.MeterIds)).Distinct().ToList(); + var virtuals = new SortedSet(); + foreach (var id in shown) + { + CollectVirtuals(id, virtuals); + } + + foreach (var id in virtuals) + { + var meter = _catalog.Meters[id]; + switch (meter.VirtualStatus) + { + case VirtualMeterStatus.Legacy: + _problems.Add(new AnalysisProblem(AnalysisProblemKind.LegacyDefinition, id) + { + MeterIds = meter.Legacy?.MeterIds ?? [], + Legacy = meter.Legacy?.Outcome, + }); + break; + case VirtualMeterStatus.NeedsConfiguration: + _problems.Add(new AnalysisProblem(AnalysisProblemKind.LegacyNeedsConfiguration, id) + { + MeterIds = meter.Legacy?.MeterIds ?? [], + Values = meter.Legacy?.Values ?? [], + Legacy = meter.Legacy?.Outcome, + }); + break; + case VirtualMeterStatus.Malformed: + _problems.Add(new AnalysisProblem(AnalysisProblemKind.MalformedDefinition, id) + { + Values = meter.StoredDefinition?.Problem is { } malformation ? [malformation] : [], + }); + break; + case VirtualMeterStatus.Invalid: + foreach (var problem in meter.Validation?.Problems ?? []) + { + _problems.Add(new AnalysisProblem(AnalysisProblemKind.InvalidDefinition, id) + { + MeterIds = problem.MeterIds, + Values = problem.Values, + Virtual = problem, + }); + } + + break; + } + } + + foreach (var id in _dataLeaves.Order()) + { + if (_leaves[id].Meter.IsPending) + { + _problems.Add(new AnalysisProblem(AnalysisProblemKind.AnalysisPending, id)); + } + + if (_afterNow.GetValueOrDefault(id) is { } block) + { + _problems.Add(new AnalysisProblem(AnalysisProblemKind.RecordedAfterNow, id) { AfterNow = block }); + } + + if (_freshness.GetValueOrDefault(id) is { State: FreshnessState.Stale }) + { + _problems.Add(new AnalysisProblem(AnalysisProblemKind.StaleSource, id)); + } + } + + if (_request.Scope.Kind == AnalysisScopeKind.Meters) + { + return; + } + + var types = TypesInScope(); + foreach (var problem in _catalog.Totals.Problems.Where(p => _catalog.Find(p.MeterId) is { } m && types.Contains(m.EnergyTypeId))) + { + _problems.Add(new AnalysisProblem(AnalysisProblemKind.TotalsProblem, problem.MeterId) + { + MeterIds = problem.OtherMeterId is { } other ? [other] : [], + Totals = problem, + }); + } + + foreach (var hint in _catalog.Totals.Hints.Where(h => types.Contains(h.EnergyTypeId))) + { + _problems.Add(new AnalysisProblem(AnalysisProblemKind.PossibleOverlap, hint.MeterId) { MeterIds = [hint.OtherMeterId], Hint = hint }); + } + } + + private void CollectVirtuals(int meterId, SortedSet found) + { + if (_catalog.Find(meterId) is not { IsVirtual: true } meter || !found.Add(meterId)) + { + return; + } + + foreach (var source in meter.Formula?.MeterIds ?? []) + { + CollectVirtuals(source, found); + } + } + + private HashSet TypesInScope() => _request.Scope.Kind == AnalysisScopeKind.EnergyType + ? [_request.Scope.EnergyTypeId!.Value] + : [.. _catalog.Meters.Values.Select(m => m.EnergyTypeId)]; + + private IReadOnlyList Classification() + { + if (_request.Scope.Kind == AnalysisScopeKind.Meters) + { + return []; + } + + return [.. ScopeMeters().Order() + .Where(id => _catalog.Totals.Meters.ContainsKey(id)) + .Select(id => new MeterClassification(id, _catalog.Meters[id].Name, _catalog.Totals.Meters[id]))]; + } + + private static List Deduplicated(List problems) + { + var seen = new HashSet<(AnalysisProblemKind, int?, VirtualProblemKind?, TotalsProblemKind?, OverlapHintKind?)>(); + return [.. problems.Where(p => seen.Add((p.Kind, p.MeterId, p.Virtual?.Kind, p.Totals?.Kind, p.Hint?.Kind)))]; + } + + private static string MeterKey(int meterId) => SeriesKey.ForMeter(meterId, 0, string.Empty).Id; + + private static string MeasureKey(int energyTypeId, MeasureGroup group) => SeriesKey.ForMeasure(energyTypeId, group.Measure, group.Unit).Id; + + /// The values of one physical meter on one side. + private sealed record PhysicalValues( + IReadOnlyList Values, BucketValue Total, IReadOnlyList? Coverage, BucketCoverage? TotalCoverage); + + /// + /// One side of a request — the current period or its comparison — with its buckets, the bucket that is the whole + /// period, the cut-off its actuals stop at, and what has been computed for it. + /// + private sealed class Side(IReadOnlyList buckets, AnalysisBucket periodBucket, DateTimeOffset cutoff) + { + private List? _dayWindows; + + public IReadOnlyList Buckets { get; } = buckets; + + public AnalysisBucket PeriodBucket { get; } = periodBucket; + + public DateTimeOffset Cutoff { get; } = cutoff; + + public Dictionary Tallies { get; } = []; + + public Dictionary<(int, bool), PhysicalValues> Physical { get; } = []; + + public Dictionary Evaluations { get; } = []; + + public Dictionary Sources { get; } = []; + + /// The local days the buckets span, each clipped to the buckets' range. + public List DayWindows(TimeZoneInfo zone) + { + if (_dayWindows is not null) + { + return _dayWindows; + } + + _dayWindows = []; + if (Buckets.Count == 0) + { + return _dayWindows; + } + + var from = Buckets[0].From; + var to = Buckets[^1].To; + if (to <= from) + { + return _dayWindows; + } + + for (var day = RangeParts.LocalDate(from, zone); ; day = day.AddDays(1)) + { + var start = Core.Normalization.GapAttribution.LocalMidnight(day, zone); + if (start >= to) + { + break; + } + + var end = Core.Normalization.GapAttribution.LocalMidnight(day.AddDays(1), zone); + var windowFrom = start > from ? start : from; + var windowTo = end < to ? end : to; + if (windowTo > windowFrom) + { + _dayWindows.Add(new DayWindow(day, windowFrom, windowTo)); + } + } + + return _dayWindows; + } + } + + /// One local day of a side, clipped to its range. + private readonly record struct DayWindow(DateOnly Day, DateTimeOffset From, DateTimeOffset To); +} diff --git a/src/Infrastructure/Analysis/LeafData.cs b/src/Infrastructure/Analysis/LeafData.cs new file mode 100644 index 0000000..b4ea587 --- /dev/null +++ b/src/Infrastructure/Analysis/LeafData.cs @@ -0,0 +1,530 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Rollups; +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; + +namespace MeterVault.Infrastructure.Analysis; + +/// A piece of a range as the stored data can answer it: a whole local month, a whole local day, or part of a day. +internal readonly record struct RangePart(RangePartKind Kind, DateOnly Day, DateTimeOffset From, DateTimeOffset To); + +internal enum RangePartKind +{ + Month, + Day, + PartialDay, +} + +/// +/// Cuts [from, to) into what the rollups can sum (D-15): whole local months from the month table (when +/// allowed), whole local days from the day table, and at most one partial day at each end, read from +/// consumption directly. +/// +internal static class RangeParts +{ + public static IEnumerable Of(DateTimeOffset from, DateTimeOffset to, TimeZoneInfo zone, bool useMonths) + { + var start = from.ToUniversalTime(); + var end = to.ToUniversalTime(); + if (end <= start) + { + yield break; + } + + var day = LocalDate(start, zone); + while (start < end) + { + var dayStart = GapAttribution.LocalMidnight(day, zone); + var dayEnd = GapAttribution.LocalMidnight(day.AddDays(1), zone); + if (dayEnd <= start) + { + // Contradictory zone data can file an instant under a day that already ended; move on. + day = day.AddDays(1); + continue; + } + + if (start != dayStart || dayEnd > end) + { + var partEnd = dayEnd < end ? dayEnd : end; + yield return new RangePart(RangePartKind.PartialDay, day, start, partEnd); + start = partEnd; + day = day.AddDays(1); + continue; + } + + if (useMonths && day.Day == 1) + { + var next = day.AddMonths(1); + var monthEnd = GapAttribution.LocalMidnight(next, zone); + if (monthEnd <= end) + { + yield return new RangePart(RangePartKind.Month, day, start, monthEnd); + start = monthEnd; + day = next; + continue; + } + } + + yield return new RangePart(RangePartKind.Day, day, start, dayEnd); + start = dayEnd; + day = day.AddDays(1); + } + } + + public static DateOnly LocalDate(DateTimeOffset instant, TimeZoneInfo zone) => + DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(instant, zone).DateTime); +} + +/// A day's or month's rollup of one meter, both kinds together (one meter measures one kind). +internal sealed class RollupAggregate +{ + public double Amount { get; private set; } + + public double Measured { get; private set; } + + public double Manual { get; private set; } + + public double Imported { get; private set; } + + public double Estimated { get; private set; } + + public int Rows { get; private set; } + + public RollupFlags Flags { get; private set; } + + public DateTimeOffset MaxIntervalEnd { get; private set; } = DateTimeOffset.MinValue; + + public bool HasOpeningBalance => Flags.HasFlag(RollupFlags.OpeningBalance); + + public void Add(RollupBucket bucket) + { + Amount += bucket.Amount; + Measured += bucket.Measured; + Manual += bucket.Manual; + Imported += bucket.Imported; + Estimated += bucket.Estimated; + Rows += bucket.Rows; + Flags |= bucket.Flags; + var end = bucket.MaxIntervalEnd.ToUniversalTime(); + if (end > MaxIntervalEnd) + { + MaxIntervalEnd = end; + } + } +} + +/// A consumption row read for a partial day. +internal readonly record struct RawRow(DateTimeOffset Time, double Amount, ReadingQuality Quality); + +/// +/// What the database summed over one exact window of a partial day (D-15): the amount by quality and the row count, +/// with the rollup writer's mapping of qualities (estimated, interpolated and unknown are inferred amounts). +/// +internal readonly record struct WindowSum(double Amount, double Measured, double Manual, double Imported, double Estimated, int Rows); + +/// What a window of one meter sums to: the actual amount by quality, and what was left out as closing after the cut. +internal sealed class Tally +{ + public double Amount { get; private set; } + + public double Measured { get; private set; } + + public double Manual { get; private set; } + + public double Imported { get; private set; } + + public double Estimated { get; private set; } + + public int Rows { get; private set; } + + public bool OpeningBalance { get; set; } + + public int AfterRows { get; private set; } + + public double AfterAmount { get; private set; } + + public DateOnly? AfterFirstDay { get; private set; } + + public DateOnly? AfterLastDay { get; private set; } + + public Provenance ProvenanceWith(Provenance implied) => + ProvenanceRules.ProvenanceOf(Measured, Manual, Imported, Estimated, OpeningBalance, derived: false) + | (Math.Abs(Amount) > ProvenanceRules.Epsilon ? implied : Provenance.None); + + public void Add(RollupAggregate aggregate) + { + Amount += aggregate.Amount; + Measured += aggregate.Measured; + Manual += aggregate.Manual; + Imported += aggregate.Imported; + Estimated += aggregate.Estimated; + Rows += aggregate.Rows; + OpeningBalance |= aggregate.HasOpeningBalance; + } + + public void Add(RawRow row) + { + Amount += row.Amount; + Rows++; + switch (row.Quality) + { + case ReadingQuality.Measured: + Measured += row.Amount; + break; + case ReadingQuality.Manual: + Manual += row.Amount; + break; + case ReadingQuality.Imported: + Imported += row.Amount; + break; + default: + // Estimated, interpolated and anything unknown are inferred amounts (the rollup writer's mapping). + Estimated += row.Amount; + break; + } + } + + public void Add(WindowSum sum) + { + Amount += sum.Amount; + Measured += sum.Measured; + Manual += sum.Manual; + Imported += sum.Imported; + Estimated += sum.Estimated; + Rows += sum.Rows; + } + + public void Add(Tally other) + { + Amount += other.Amount; + Measured += other.Measured; + Manual += other.Manual; + Imported += other.Imported; + Estimated += other.Estimated; + Rows += other.Rows; + OpeningBalance |= other.OpeningBalance; + if (other.AfterRows > 0) + { + AddAfter(other.AfterRows, other.AfterAmount, other.AfterFirstDay!.Value, other.AfterLastDay!.Value); + } + } + + public void AddAfter(int rows, double amount, DateOnly firstDay, DateOnly lastDay) + { + if (rows <= 0) + { + return; + } + + AfterRows += rows; + AfterAmount += amount; + AfterFirstDay = AfterFirstDay is { } first && first <= firstDay ? first : firstDay; + AfterLastDay = AfterLastDay is { } last && last >= lastDay ? last : lastDay; + } +} + +/// +/// The stored data of one physical meter that a request reads: coverage runs, the rollup days and months and the +/// partial-day rows it asked for, and the rows beyond the range that close after now. +/// +/// +/// Every window a request sums is first registered (), which records the months, days and +/// partial days its parts need; the queries then load exactly those, and refuses a part that was +/// not registered rather than reading its absence as a zero. +/// +internal sealed class LeafData(AnalysisMeter meter, TimeZoneInfo zone) +{ + private readonly Dictionary _days = []; + private readonly Dictionary _months = []; + private readonly Dictionary> _raw = []; + private readonly HashSet _requestedDays = []; + private readonly HashSet _requestedMonths = []; + private readonly HashSet _requestedRawDays = []; + private readonly Dictionary<(DateTimeOffset From, DateTimeOffset To), WindowSum> _windowSums = []; + private readonly Dictionary> _capped = []; + private IReadOnlyList _runs = []; + private IReadOnlyList? _serviceRuns; + + public AnalysisMeter Meter { get; } = meter; + + public int Id => Meter.Id; + + /// Whether whole months come from the month table (month/year buckets of a meter no virtual meter reads by day). + public bool UseMonths { get; set; } + + /// True when a virtual meter reads this meter day by day. + public bool IsVirtualSource { get; set; } + + /// The stored coverage runs, uncapped (A-04). + public IReadOnlyList Runs + { + get => _runs; + set + { + _runs = value; + _serviceRuns = null; + } + } + + /// The runs plus the known zeros outside the service period (D-24), for the meter as a contributor. + public IReadOnlyList ServiceRuns => + _serviceRuns ??= LifecycleCoverage.WithService(_runs, Meter.Meter.InstalledAt, Meter.Meter.RetiredAt, zone); + + /// Where the meter's opening balance is booked (A-01), when it has one and it was asked for. + public DateTimeOffset? OpeningBalanceStamp { get; set; } + + /// Rollup days beyond the range that close after now (D-04). + public List<(DateOnly Day, RollupAggregate Rollup)> AfterNowDays { get; } = []; + + public IReadOnlyCollection RequestedDays => _requestedDays; + + public IReadOnlyCollection RequestedMonths => _requestedMonths; + + public IReadOnlyCollection RequestedRawDays => _requestedRawDays; + + /// The exact partial-day windows the database sums for this meter (). + public IReadOnlyCollection<(DateTimeOffset From, DateTimeOffset To)> RequestedWindows => _windowSums.Keys; + + /// Registers a window this request will sum. + public void Request(DateTimeOffset from, DateTimeOffset to) + { + foreach (var part in RangeParts.Of(from, to, zone, UseMonths)) + { + switch (part.Kind) + { + case RangePartKind.Month: + _requestedMonths.Add(part.Day); + break; + case RangePartKind.Day: + _requestedDays.Add(part.Day); + break; + default: + // A partial day needs its rows and its rollup: the rollup tells whether a row of the day closes + // after the day (a month label), and where an opening balance is booked. + _requestedDays.Add(part.Day); + _requestedRawDays.Add(part.Day); + break; + } + } + } + + /// + /// Registers a window that is only summed, never charted or checked for an opening balance: a matched piece of a + /// comparison (D-07). Its whole days and months come from the rollups like any window's; its partial edge days are + /// summed by the database over their exact bounds, up to , instead of being loaded row by + /// row (D-15). Matched coverage cuts a piece at every hole, so a meter sampled on change with nightly gaps has one + /// piece per covered stretch, and loading their days' rows would stream the meter's whole history. + /// + public void RequestSummed(DateTimeOffset from, DateTimeOffset to, DateTimeOffset cutoff) + { + foreach (var part in RangeParts.Of(from, to, zone, UseMonths)) + { + switch (part.Kind) + { + case RangePartKind.Month: + _requestedMonths.Add(part.Day); + break; + case RangePartKind.Day: + _requestedDays.Add(part.Day); + break; + default: + // The day's rollup still tells whether a row of the day closes after the day (A-05). + _requestedDays.Add(part.Day); + if (SummedEnd(part, cutoff) is { } end) + { + _windowSums.TryAdd((part.From, end), default); + } + + break; + } + } + } + + /// Stores what the database summed over a registered window. + public void SetWindowSum(DateTimeOffset from, DateTimeOffset to, WindowSum sum) => _windowSums[(from, to)] = sum; + + /// Registers a whole local day's rows (the partial current day, whose later rows close after now). + public void RequestRawDay(DateOnly day) + { + _requestedDays.Add(day); + _requestedRawDays.Add(day); + } + + public void AddDay(DateOnly day, RollupBucket bucket) => Aggregate(_days, day).Add(bucket); + + public void AddMonth(DateOnly month, RollupBucket bucket) => Aggregate(_months, month).Add(bucket); + + public void AddRaw(RawRow row) + { + var day = RangeParts.LocalDate(row.Time, zone); + if (!_raw.TryGetValue(day, out var rows)) + { + _raw[day] = rows = []; + } + + rows.Add(row); + } + + /// Sorts the raw rows once loaded. + public void Seal() + { + foreach (var rows in _raw.Values) + { + rows.Sort((a, b) => a.Time.CompareTo(b.Time)); + } + } + + /// + /// Sums [from, to) as actuals up to (the side's now): a rollup whose rows close + /// after the cutoff is left out and counted as such (A-05), and so is a partial day holding a row that closes + /// after both the day and the cutoff — a month label, which cannot be told apart from the day's other rows. A + /// partial day comes from its loaded rows when its raw day was registered, otherwise from the database's sum over + /// the exact window (). + /// + public Tally Sum(DateTimeOffset from, DateTimeOffset to, DateTimeOffset cutoff) + { + var tally = new Tally(); + foreach (var part in RangeParts.Of(from, to, zone, UseMonths)) + { + switch (part.Kind) + { + case RangePartKind.Month: + Require(_requestedMonths, part.Day, "month"); + AddRollup(tally, _months.GetValueOrDefault(part.Day), cutoff, part.Day, part.Day.AddMonths(1).AddDays(-1)); + break; + case RangePartKind.Day: + Require(_requestedDays, part.Day, "day"); + AddRollup(tally, _days.GetValueOrDefault(part.Day), cutoff, part.Day, part.Day); + break; + default: + AddPartial(tally, part, cutoff); + break; + } + } + + return tally; + } + + /// The rows of a registered raw day closing at or after . + public IEnumerable RawFrom(DateOnly day, DateTimeOffset from) + { + Require(_requestedRawDays, day, "raw day"); + return _raw.GetValueOrDefault(day)?.Where(r => r.Time >= from) ?? []; + } + + /// The coverage runs as of (A-04), cached per cutoff. + public IReadOnlyList CappedRuns(DateTimeOffset cutoff) + { + if (!_capped.TryGetValue(cutoff, out var capped)) + { + _capped[cutoff] = capped = CoverageRuns.CapAt(Runs, cutoff, zone); + } + + return capped; + } + + private void AddPartial(Tally tally, RangePart part, DateTimeOffset cutoff) + { + if (!_requestedRawDays.Contains(part.Day)) + { + AddSummed(tally, part, cutoff); + return; + } + + var rollup = _days.GetValueOrDefault(part.Day); + var rows = _raw.GetValueOrDefault(part.Day) ?? []; + var inWindow = rows.Where(r => r.Time >= part.From && r.Time < part.To && r.Time < cutoff).ToList(); + + if (ClosesAfter(rollup, part.Day, cutoff)) + { + tally.AddAfter(inWindow.Count, inWindow.Sum(r => r.Amount), part.Day, part.Day); + return; + } + + foreach (var row in inWindow) + { + tally.Add(row); + } + + // The opening balance is the meter's first row, so the day's earliest row carries it. + if (rollup is { HasOpeningBalance: true } && rows.Count > 0 && rows[0].Time >= part.From && rows[0].Time < part.To) + { + tally.OpeningBalance = true; + } + } + + /// A partial day summed by the database (), withheld like a loaded one (A-05). + private void AddSummed(Tally tally, RangePart part, DateTimeOffset cutoff) + { + if (SummedEnd(part, cutoff) is not { } end) + { + return; + } + + if (!_windowSums.TryGetValue((part.From, end), out var sum)) + { + Require(_requestedRawDays, part.Day, "raw day"); + } + + Require(_requestedDays, part.Day, "day"); + if (ClosesAfter(_days.GetValueOrDefault(part.Day), part.Day, cutoff)) + { + tally.AddAfter(sum.Rows, sum.Amount, part.Day, part.Day); + return; + } + + tally.Add(sum); + } + + /// A partial day's window up to the cutoff, or null when nothing of it lies before the cutoff. + private static DateTimeOffset? SummedEnd(RangePart part, DateTimeOffset cutoff) + { + var end = part.To < cutoff ? part.To : cutoff; + return end > part.From ? end : null; + } + + /// + /// True when a row of the day closes after both the day and the cutoff (a month label, which cannot be told apart + /// from the day's other rows), so the day's part is withheld whole (A-05). + /// + private bool ClosesAfter(RollupAggregate? rollup, DateOnly day, DateTimeOffset cutoff) => + rollup is not null + && rollup.MaxIntervalEnd > GapAttribution.LocalMidnight(day.AddDays(1), zone) + && rollup.MaxIntervalEnd > cutoff; + + private static void AddRollup(Tally tally, RollupAggregate? rollup, DateTimeOffset cutoff, DateOnly first, DateOnly last) + { + if (rollup is null) + { + return; + } + + if (rollup.MaxIntervalEnd > cutoff) + { + tally.AddAfter(rollup.Rows, rollup.Amount, first, last); + return; + } + + tally.Add(rollup); + } + + private void Require(HashSet requested, DateOnly key, string what) + { + if (!requested.Contains(key)) + { + throw new InvalidOperationException( + $"The analysis reader summed {what} {key:yyyy-MM-dd} of meter {Id} without loading it; register every window before loading."); + } + } + + private static RollupAggregate Aggregate(Dictionary map, DateOnly key) + { + if (!map.TryGetValue(key, out var aggregate)) + { + map[key] = aggregate = new RollupAggregate(); + } + + return aggregate; + } +} diff --git a/src/Infrastructure/Analysis/MeterDraftAnalysis.cs b/src/Infrastructure/Analysis/MeterDraftAnalysis.cs new file mode 100644 index 0000000..b11d74a --- /dev/null +++ b/src/Infrastructure/Analysis/MeterDraftAnalysis.cs @@ -0,0 +1,239 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Analysis; + +/// +/// A meter as the meter editor holds it before it is saved: what analysis reads of it (D-20 – D-26). The editor asks +/// what this meter would do — validate its calculation, check its totals override, evaluate its preview — without +/// storing anything. +/// +/// The stored meter being edited; 0 (or any id below 1) for a meter not created yet. +/// Its name (user data). +/// Its energy type. +/// Its measurement mode. +/// Its raw register unit (a virtual meter's comes from its definition). +public sealed record MeterDraft(int MeterId, string Name, short EnergyTypeId, MeterMode Mode, string Unit) +{ + /// The id a meter that does not exist yet has in an overlaid catalog; no formula can refer to it. + public const int NewMeterId = -1; + + /// The role token the draft asks for; dropped where its mode may not hold it (A-07). + public string? Role { get; init; } + + /// Start of the service period (D-24). + public DateOnly? InstalledAt { get; init; } + + /// End of the service period (D-24). + public DateOnly? RetiredAt { get; init; } + + /// For a virtual draft, its calculation as typed; kind, unit and cost rule may still be left to inference. + public VirtualDefinition? Definition { get; init; } + + /// + /// The meters the draft's incoming topology links would come from (the "sub-meter of" selection, or a sum's sources + /// when the editor syncs them); null keeps the stored links. + /// + public IReadOnlyCollection? Upstream { get; init; } + + /// The id the draft has in a catalog: its own, or for a new meter. + public int CatalogId => MeterId > 0 ? MeterId : NewMeterId; +} + +/// +/// Analysis questions about a meter being edited (brief §5.1, D-23, D-26, D-31): whether its calculation is valid, +/// whether its totals override may be saved, and what its calculation would show over a period — each answered by the +/// same catalog, validator, policy and reader every page uses, with the draft laid over the stored meters in memory. +/// Nothing is written. +/// +/// +/// +/// The overlay is the stored catalog rebuilt with the draft in place of the stored meter (or added, for a new one) and, +/// when given, the draft's incoming links in place of the stored ones. Rebuilding validates every virtual definition +/// again in dependency order, so a meter whose formula reads the draft sees the draft's kind and unit — exactly what a +/// save would produce. +/// +/// +/// The preview reads the draft through on that overlay: its sources' rollups, their joint +/// coverage, missing versus zero, the dependency paths — never a second evaluator. It needs no rows of the draft +/// itself, because a virtual meter stores none (D-12, D-27). +/// +/// +public sealed class MeterDraftAnalysis(AnalysisReader reader, IDbContextFactory contextFactory) +{ + private readonly AnalysisReader _reader = reader; + private readonly IDbContextFactory _contextFactory = contextFactory; + + /// The zone a preview period must be resolved in (the reader's). + public TimeZoneInfo Zone => _reader.Zone; + + /// The stored catalog, as a request sees it. + public Task LoadCatalogAsync(CancellationToken cancellationToken = default) => + _reader.LoadCatalogAsync(cancellationToken); + + /// + /// Validates the draft's calculation as the definition of the draft meter against the stored meters (D-26): syntax, + /// references, loops through nested virtual meters (with the path), kinds and units, and the cost rule. + /// + /// The draft has no definition. + public static VirtualValidation Validate(AnalysisCatalog catalog, MeterDraft draft) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(draft); + if (draft.Definition is not { } definition) + { + throw new ArgumentException("The draft has no calculation to validate.", nameof(draft)); + } + + return VirtualValidator.Validate(definition, draft.CatalogId, catalog.Catalog); + } + + /// + /// The stored catalog with the draft in place of its meter. A virtual draft carries its effective definition (A-08) + /// when it has one; without one it has no calculation in the overlay. The stored totals override is kept, because + /// applies the requested one on top of the stored ones. + /// + public static AnalysisCatalog Overlay(AnalysisCatalog catalog, MeterDraft draft, VirtualDefinition? effectiveDefinition) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(draft); + + var id = draft.CatalogId; + var stored = catalog.Find(id); + var meta = MeterMeta.SetRole(stored?.Meter.Meta ?? "{}", MeterRoleAssignment.AllowedToken(draft.Mode, draft.Role)); + meta = draft.Mode == MeterMode.Virtual && effectiveDefinition is not null + ? VirtualDefinitionJson.Write(meta, effectiveDefinition) + : VirtualDefinitionJson.Remove(meta); + + var meter = new Meter + { + Id = id, + Name = draft.Name, + EnergyTypeId = draft.EnergyTypeId, + Mode = draft.Mode, + Unit = draft.Unit, + InstalledAt = draft.InstalledAt, + RetiredAt = draft.RetiredAt, + InitialBaseline = stored?.Meter.InitialBaseline ?? 0, + IsActive = stored?.Meter.IsActive ?? true, + Meta = meta, + }; + + var others = catalog.Meters.Values.Where(m => m.Id != id).ToList(); + var tanks = others.Select(m => m.Tank); + if (draft.Mode != MeterMode.Virtual && stored?.Tank is { } tank) + { + tanks = tanks.Append(tank); + } + + IEnumerable links = catalog.Links; + if (draft.Upstream is { } upstream) + { + links = links + .Where(l => l.ToMeterId != id) + .Concat(upstream.Where(from => from != id && catalog.Find(from) is not null).Distinct() + .Select(from => new MeterLink { FromMeterId = from, ToMeterId = id })); + } + + var states = catalog.Meters.Values.Select(m => m.State); + return AnalysisCatalog.Build( + others.Select(m => m.Meter).Append(meter), + tanks.OfType(), + links, + states.OfType(), + catalog.Zone); + } + + /// + /// Whether the draft may be saved with as its totals override (D-23), in the overlaid + /// configuration: an that would count something twice is refused, naming the + /// other meter; so is a change that would push another meter's out. + /// + public static TotalsOverrideCheck CheckTotals(AnalysisCatalog overlay, MeterDraft draft, TotalsOverride requested) + { + ArgumentNullException.ThrowIfNull(overlay); + ArgumentNullException.ThrowIfNull(draft); + + return TotalsPolicy.Validate( + overlay.TotalsMeters, + overlay.Links.Select(l => new TotalsLink(l.FromMeterId, l.ToMeterId)), + draft.CatalogId, + requested); + } + + /// + /// What the draft's calculation shows over in buckets (D-31): the + /// draft's series — values, total, status, and each source's contribution — read through the shared reader on the + /// overlay. The overlay must carry the draft's effective definition (). + /// + /// The period was resolved in another zone than the reader's. + public async Task PreviewAsync( + AnalysisCatalog overlay, MeterDraft draft, ResolvedPeriod period, BucketSize bucket = BucketSize.Month, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(overlay); + ArgumentNullException.ThrowIfNull(draft); + ArgumentNullException.ThrowIfNull(period); + + var request = new AnalysisRequest(AnalysisScope.ForMeter(draft.CatalogId), period) { Bucket = bucket }; + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + return await _reader.ReadAsync(db, overlay, request, cancellationToken).ConfigureAwait(false); + } + + /// + /// The dates the draft's sources have data for as of (D-19), together — what "all available + /// history" spans in the preview, however old it is; null when no source has any, or the draft has no calculation. + /// + public async Task SourceAvailabilityAsync( + AnalysisCatalog overlay, MeterDraft draft, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(overlay); + ArgumentNullException.ThrowIfNull(draft); + + var sources = draft.Definition?.ReferencedMeterIds.Where(id => id != draft.CatalogId && overlay.Find(id) is not null).Distinct().ToList() ?? []; + if (sources.Count == 0) + { + return null; + } + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + return await _reader.QuantityAvailabilityAsync(db, overlay, sources, now, cancellationToken).ConfigureAwait(false); + } + + /// + /// Returns with the totals override set (D-23), or removed for + /// , keeping every other key. Meta that is not a JSON object is replaced by one. + /// + public static string WithTotalsOverride(string? meta, TotalsOverride value) + { + JsonObject root; + try + { + root = string.IsNullOrWhiteSpace(meta) ? new JsonObject() : JsonNode.Parse(meta) as JsonObject ?? new JsonObject(); + + // JsonObject materializes its properties lazily, and a duplicate key only fails there: force it inside the guard. + _ = root.Count; + } + catch (Exception ex) when (ex is JsonException or ArgumentException or InvalidOperationException) + { + root = new JsonObject(); + } + + if (value == TotalsOverride.Auto) + { + root.Remove(TotalsOverrideTokens.MetaKey); + } + else + { + root[TotalsOverrideTokens.MetaKey] = TotalsOverrideTokens.ToToken(value); + } + + return root.ToJsonString(); + } +} diff --git a/src/Infrastructure/Analysis/MeterRoleAssignment.cs b/src/Infrastructure/Analysis/MeterRoleAssignment.cs new file mode 100644 index 0000000..f00a560 --- /dev/null +++ b/src/Infrastructure/Analysis/MeterRoleAssignment.cs @@ -0,0 +1,84 @@ +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Normalization; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Analysis; + +/// +/// Saving a meter's role (D-21, A-07): the one place that keeps a role unique per energy type and only on meters whose +/// mode may hold it, so the meter editor cannot leave two grid meters in service where analysis keeps the lower id and +/// drops the other from every measure and from the bill. +/// +public static class MeterRoleAssignment +{ + /// + /// The role token a meter of may store: in its canonical spelling + /// when it names a role the mode may hold (), otherwise none. A virtual, + /// generation, runtime or tank meter holds no role (A-07). + /// + public static string? AllowedToken(MeterMode mode, string? token) => + MeterRoleRules.Effective(mode, token) is { } role ? MeterRoleRules.Token(role) : null; + + /// + /// The meter in service the role would move away from if took it, for the editor to name + /// before saving; null when the role is free, or when the taker is retired (it keeps the role for its history but + /// takes it from nobody). + /// + public static Meter? CurrentHolder(IEnumerable meters, MeterRole role, short energyTypeId, int meterId, bool takerRetired) => + takerRetired ? null : MeterRoleRules.CurrentHolder(meters, role, energyTypeId, meterId); + + /// + /// Applies the stored role of meter , inside the caller's transaction and after the meter + /// itself was saved: a role its mode may not hold is dropped, and a role it holds in service is taken from every other + /// holder in service of its energy type (). + /// A meter whose role changes this way is recomputed, because its rollup state records the kind the role gives it + /// (D-20: a grid_export meter measures export). Returns the meters the role moved from. + /// + public static async Task> ApplyAsync( + MeterVaultDbContext db, int meterId, NormalizationService normalization, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(normalization); + + var meter = await db.Meters.FirstAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); + var stored = MeterMeta.Role(meter.Meta); + var allowed = AllowedToken(meter.Mode, stored); + if (stored is not null && allowed is null) + { + meter.Meta = MeterMeta.SetRole(meter.Meta, null); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await normalization.RecomputeMeterAsync(meter.Id, null, cancellationToken).ConfigureAwait(false); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return []; + } + + if (MeterRoleRules.Parse(allowed) is not { } role) + { + return []; + } + + var sameType = await db.Meters.Where(m => m.EnergyTypeId == meter.EnergyTypeId).ToListAsync(cancellationToken).ConfigureAwait(false); + var displaced = MeterRoleRules.Displaced(sameType, meter, role); + if (displaced.Count == 0) + { + return []; + } + + foreach (var holder in displaced) + { + holder.Meta = MeterMeta.SetRole(holder.Meta, null); + holder.UpdatedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + foreach (var holder in displaced) + { + await normalization.RecomputeMeterAsync(holder.Id, null, cancellationToken).ConfigureAwait(false); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + return displaced; + } +} diff --git a/src/Infrastructure/Analysis/VirtualDefinitionUpgrade.cs b/src/Infrastructure/Analysis/VirtualDefinitionUpgrade.cs new file mode 100644 index 0000000..1bbdcd3 --- /dev/null +++ b/src/Infrastructure/Analysis/VirtualDefinitionUpgrade.cs @@ -0,0 +1,174 @@ +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Normalization; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace MeterVault.Infrastructure.Analysis; + +/// What one run of did (D-28). +/// The legacy meters that got their implied sum stored as an explicit definition, in dependency order. +/// The legacy meters whose links imply nothing evaluable, with the reason. +/// The meters whose conversion could not be saved; they stay legacy and are tried again at the next start. +public sealed record VirtualDefinitionUpgradeResult( + IReadOnlyList Converted, + IReadOnlyList NeedsConfiguration, + IReadOnlyList Failed) +{ + public static VirtualDefinitionUpgradeResult None { get; } = new([], [], []); +} + +/// +/// Stores the calculation of every expression-less ("legacy") virtual meter whose topology implies one (D-28): the +/// same-type sum its incoming links name, when those sources share one unit and one kind. Runs at startup, after the +/// reference seed and before . +/// +/// +/// +/// Before the analysis rework a virtual meter had no formula; the flow view summed its incoming links. The reader +/// evaluates such a meter as that sum with the status "legacy — confirm" until it has a definition of its own +/// (). This writes exactly what the reader would evaluate — the catalog's +/// derivation, validated against the whole catalog in dependency order — as the meter's definition +/// (: the effective kind, unit and cost rule, A-08), so from then on the links +/// are topology only and a later link edit no longer changes the calculation (brief §5.2). +/// +/// +/// Anything the links do not make unambiguous — mixed units or kinds, no same-type source, a loop, a source that +/// needs configuration itself — is left alone and logged as needing configuration; the reader reports it on every +/// analysis of the meter (), so nothing is stored for it. A meter +/// whose stored definition is malformed is never touched: its links must not overwrite a broken formula. Only the +/// definition keys of Meter.Meta are written; a role or any other key stays as it is, and raw readings are +/// never involved. +/// +/// +/// The run is idempotent: a converted meter has an expression and is never legacy again, so a rerun converts +/// nothing. Each meter is saved in its own transaction, together with its rollup state (the kind and unit its +/// definition now declares), and a failure is logged and retried at the next start — like the normalization +/// upgrade, this must never keep the application from starting. +/// +/// +public sealed class VirtualDefinitionUpgrade( + MeterVaultDbContext db, NormalizationService normalization, ILogger logger) +{ + private readonly MeterVaultDbContext _db = db; + private readonly NormalizationService _normalization = normalization; + private readonly ILogger _logger = logger; + + /// Converts the legacy virtual meters that can be converted and logs the counts. + public async Task RunAsync(CancellationToken cancellationToken = default) + { + AnalysisCatalog catalog; + try + { + var anyVirtual = await _db.Meters.AsNoTracking() + .AnyAsync(m => m.Mode == MeterMode.Virtual, cancellationToken).ConfigureAwait(false); + if (!anyVirtual) + { + return VirtualDefinitionUpgradeResult.None; + } + + catalog = await AnalysisCatalog.LoadAsync(_db, _normalization.TimeZone, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Could not read the virtual meters; legacy definitions are converted at the next start"); + return VirtualDefinitionUpgradeResult.None; + } + + // Dependencies first: a legacy sum over another legacy sum is stored after it, as the derivation saw them. + var order = catalog.Graph.EvaluationOrder + .Concat(catalog.Meters.Keys.Order()) + .Distinct() + .ToList(); + var candidates = order + .Select(catalog.Find) + .OfType() + .Where(m => m.IsVirtual && m.StoredDefinition?.Status == VirtualDefinitionReadStatus.Absent) + .ToList(); + + var converted = new List(); + var failed = new List(); + var unresolved = new List(); + foreach (var meter in candidates) + { + if (meter is { VirtualStatus: VirtualMeterStatus.Legacy, Definition: { } definition }) + { + if (await ConvertAsync(meter.Id, definition, cancellationToken).ConfigureAwait(false)) + { + converted.Add(meter.Id); + } + else + { + failed.Add(meter.Id); + } + } + else if (meter.Legacy is { IsDerived: false } legacy) + { + unresolved.Add(legacy); + } + else + { + // Derived from its links, but the sum fails validation against the whole catalog. + var problem = meter.Validation?.Problems.FirstOrDefault(); + unresolved.Add(new LegacyDerivation( + meter.Id, LegacyDerivationOutcome.Invalid, null, problem?.MeterIds ?? [], problem is null ? [] : [problem.Kind.ToString()])); + } + } + + if (converted.Count > 0) + { + _logger.LogInformation( + "Stored the implied sum of {Converted} legacy virtual meter(s) as an explicit definition: {MeterIds}", + converted.Count, string.Join(", ", converted)); + } + + if (unresolved.Count > 0) + { + _logger.LogWarning( + "{Unresolved} virtual meter(s) have no calculation and their links imply none; they need configuration: {Meters}", + unresolved.Count, string.Join(", ", unresolved.Select(u => $"{u.MeterId} ({u.Outcome})"))); + } + + if (failed.Count > 0) + { + _logger.LogWarning( + "The definition of {Failed} legacy virtual meter(s) could not be stored; they are evaluated as their implied sum and converted at the next start: {MeterIds}", + failed.Count, string.Join(", ", failed)); + } + + return new VirtualDefinitionUpgradeResult(converted, unresolved, failed); + } + + /// Stores one meter's definition and its rollup state in one transaction; false when that failed. + private async Task ConvertAsync(int meterId, VirtualDefinition definition, CancellationToken cancellationToken) + { + try + { + await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + var meter = await _db.Meters.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); + + // Read again inside the transaction: whatever stored an expression in the meantime is the authority. + if (meter is null || meter.Mode != MeterMode.Virtual + || VirtualDefinitionJson.Read(meter.Meta).Status != VirtualDefinitionReadStatus.Absent) + { + return true; + } + + meter.Meta = VirtualDefinitionJson.Write(meter.Meta, definition); + await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await tx.CommitAsync(cancellationToken).ConfigureAwait(false); + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Could not store the implied definition of virtual meter {MeterId}; it is retried at the next start", meterId); + return false; + } + finally + { + _db.ChangeTracker.Clear(); + } + } +} diff --git a/src/Infrastructure/Analysis/VirtualMeterService.cs b/src/Infrastructure/Analysis/VirtualMeterService.cs new file mode 100644 index 0000000..95ded27 --- /dev/null +++ b/src/Infrastructure/Analysis/VirtualMeterService.cs @@ -0,0 +1,51 @@ +namespace MeterVault.Infrastructure.Analysis; + +/// +/// A virtual meter whose calculation reads a given meter (D-33): directly, when its formula names it, or through +/// other virtual meters. +/// +/// The dependent virtual meter. +/// Its name (user data). +/// True when its own formula names the meter. +/// The dependency path from the dependent down to the meter, e.g. [9, 7, 4]. +/// How the dependent's definition stands (a legacy sum still reads the meter through its links). +public sealed record VirtualDependent(int MeterId, string Name, bool IsDirect, IReadOnlyList Path, VirtualMeterStatus Status); + +/// +/// Questions about virtual meters that are not an analysis read: which calculations a meter feeds (D-33), for the +/// delete dialog that must name them before the meter goes. +/// +public sealed class VirtualMeterService(AnalysisReader reader) +{ + private readonly AnalysisReader _reader = reader; + + /// + /// The virtual meters that depend on , directly or through other virtual meters, by name. + /// Every definition counts, even an invalid one — deleting a meter its formula names still breaks it — and so does + /// a legacy meter evaluated as the sum of its links. A meter nothing reads has none. + /// + public async Task> GetDependentsAsync(int meterId, CancellationToken cancellationToken = default) + { + var catalog = await _reader.LoadCatalogAsync(cancellationToken).ConfigureAwait(false); + return DependentsOf(catalog, meterId); + } + + /// The dependents of in a loaded catalog, ordered by name. + public static IReadOnlyList DependentsOf(AnalysisCatalog catalog, int meterId) + { + ArgumentNullException.ThrowIfNull(catalog); + + var graph = catalog.Graph; + return [.. graph.Dependents(meterId) + .Select(catalog.Find) + .OfType() + .Select(m => new VirtualDependent( + m.Id, + m.Name, + graph.DirectDependencies(m.Id).Contains(meterId), + graph.PathTo(m.Id, meterId) ?? [m.Id, meterId], + m.VirtualStatus ?? VirtualMeterStatus.NeedsConfiguration)) + .OrderBy(d => d.Name, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(d => d.MeterId)]; + } +} diff --git a/src/Infrastructure/Backup/ExportDocument.cs b/src/Infrastructure/Backup/ExportDocument.cs index a90cf55..5d511f1 100644 --- a/src/Infrastructure/Backup/ExportDocument.cs +++ b/src/Infrastructure/Backup/ExportDocument.cs @@ -9,7 +9,11 @@ namespace MeterVault.Infrastructure.Backup; /// public sealed class ExportDocument { - public int SchemaVersion { get; set; } = 1; + /// + /// 1: the original snapshot. 2: adds (D-32); virtual definitions in Meter.Meta are + /// remapped to the new meter ids on import. A version-1 document imports as before, without links. + /// + public int SchemaVersion { get; set; } = 2; public List EnergyTypes { get; set; } = []; public List CostCategories { get; set; } = []; @@ -21,5 +25,8 @@ public sealed class ExportDocument public List CostCategoryMembers { get; set; } = []; public List ManualCosts { get; set; } = []; public List MeterEvents { get; set; } = []; + + /// The meter topology (upstream → downstream); flow and legacy virtual sums depend on it. + public List MeterLinks { get; set; } = []; public List AppSettings { get; set; } = []; } diff --git a/src/Infrastructure/Backup/ExportService.cs b/src/Infrastructure/Backup/ExportService.cs index 27eb814..9d87740 100644 --- a/src/Infrastructure/Backup/ExportService.cs +++ b/src/Infrastructure/Backup/ExportService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -7,7 +8,8 @@ namespace MeterVault.Infrastructure.Backup; /// /// Exports/imports the configuration snapshot (SDD §10). Import restores into an empty instance, -/// remapping surrogate ids so foreign keys stay consistent regardless of the original ids. +/// remapping surrogate ids so foreign keys stay consistent regardless of the original ids — including +/// the meter topology links and the meter ids inside virtual-meter definitions (D-32). /// public sealed class ExportService(MeterVaultDbContext db) { @@ -54,6 +56,7 @@ public sealed class ExportService(MeterVaultDbContext db) CostCategoryMembers = await _db.CostCategoryMembers.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), ManualCosts = await _db.ManualCosts.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), MeterEvents = await _db.MeterEvents.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + MeterLinks = await _db.MeterLinks.AsNoTracking().OrderBy(l => l.Id).ToListAsync(cancellationToken).ConfigureAwait(false), AppSettings = await _db.AppSettings.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), }; @@ -91,6 +94,29 @@ public sealed class ExportService(MeterVaultDbContext db) meterMap[old] = meter.Id; } + // A virtual meter's formula names meters by id (m12), so it follows them to their new ids, and its + // referencedMeterIds are derived again. An id the document has no meter for becomes m0, which no meter has: + // the definition then reads as referring to an unknown meter, instead of silently to whichever meter + // happens to get that id here. Every other key of Meta (a role, a totals override) is kept as it is. + foreach (var meter in doc.Meters.Where(m => !string.IsNullOrWhiteSpace(m.Meta))) + { + meter.Meta = VirtualDefinitionJson.RewriteMeterIds(meter.Meta, id => meterMap.GetValueOrDefault(id, 0)); + } + + var links = new HashSet<(int From, int To)>(); + foreach (var link in doc.MeterLinks) + { + // Both ends must be meters of this document (a link to anything else would break the foreign key), and a + // pair is stored once (it is unique). + if (!meterMap.TryGetValue(link.FromMeterId, out var from) || !meterMap.TryGetValue(link.ToMeterId, out var to) + || from == to || !links.Add((from, to))) + { + continue; + } + + _db.MeterLinks.Add(new MeterLink { FromMeterId = from, ToMeterId = to }); + } + var endpointMap = await InsertMappedAsync(doc.IngestionEndpoints, e => e.Id, (e, _) => e.Id = 0, cancellationToken).ConfigureAwait(false); foreach (var source in doc.MeterSources) @@ -113,13 +139,37 @@ public sealed class ExportService(MeterVaultDbContext db) foreach (var tariff in doc.Tariffs) { - tariff.Id = 0; - tariff.ScopeId = tariff.ScopeType switch + // A meter or type price names its scope by id without a foreign key, so an export can hold the price of a + // meter deleted before it. Its id is not in the document; kept as it is, it would name whichever meter + // gets that id here and bill it. Such a price has nothing left to price: skip it, as a + // link to an unknown meter is skipped. + int? scopeId; + switch (tariff.ScopeType) { - TariffScope.Meter when tariff.ScopeId is { } id => meterMap.GetValueOrDefault(id, id), - TariffScope.EnergyType when tariff.ScopeId is { } id => typeMap.GetValueOrDefault((short)id, (short)id), - _ => tariff.ScopeId, - }; + case TariffScope.Meter: + if (tariff.ScopeId is not { } meterId || !meterMap.TryGetValue(meterId, out var mappedMeter)) + { + continue; + } + + scopeId = mappedMeter; + break; + case TariffScope.EnergyType: + if (tariff.ScopeId is not { } typeId || typeId is < short.MinValue or > short.MaxValue + || !typeMap.TryGetValue((short)typeId, out var mappedType)) + { + continue; + } + + scopeId = mappedType; + break; + default: + scopeId = tariff.ScopeId; + break; + } + + tariff.Id = 0; + tariff.ScopeId = scopeId; _db.Tariffs.Add(tariff); } diff --git a/src/Infrastructure/Costing/BillRun.cs b/src/Infrastructure/Costing/BillRun.cs new file mode 100644 index 0000000..62953ec --- /dev/null +++ b/src/Infrastructure/Costing/BillRun.cs @@ -0,0 +1,1559 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Costing; + +/// +/// One cost request from catalog to priced result: which lines, standing charges and manual costs the scope holds, the +/// quantities they need (read once, through the analysis reader), and their prices (the Core calculator). +/// +/// +/// +/// The order is fixed: catalog, tariffs, manual costs and categories are loaded once → the bill is classified with the +/// tariff book (D-35) → the scope becomes pricing groups (a type's bill, a category, a meter) → the buckets are planned +/// and cut into local months (D-36) → the quantities of every meter any group prices are read in one reader pass → each +/// group is priced twice: per bucket, and over the period's own months for its total. +/// +/// +/// A figure that is the sum of several groups (the portfolio: every type's bill plus the global standing charge and the +/// meterless manual costs) is added up with , so it reconciles with its parts exactly. +/// +/// +internal sealed class BillRun +{ + private readonly MeterVaultDbContext _db; + private readonly AnalysisReader _reader; + private readonly CostAnalysisRequest _request; + private readonly ResolvedPeriod _period; + private readonly TimeZoneInfo _zone; + private readonly string _currency; + private readonly DateOnly _today; + + private readonly HashSet _notCosted = []; + private readonly Dictionary _firstData = []; + private readonly List _quantityProblems = []; + + private AnalysisCatalog _catalog = null!; + private TariffBook _book = null!; + private TotalsClassification _full = null!; + private List _manual = []; + private List _categories = []; + private Dictionary> _virtualSources = []; + + private BucketPlan _plan = null!; + private AnalysisBucket _periodBucket = null!; + private IReadOnlyList _displayParts = []; + private IReadOnlyList _periodParts = []; + private Dictionary> _displayValues = []; + private Dictionary> _periodValues = []; + private Dictionary> _displaySpans = []; + private Dictionary> _periodSpans = []; + private Dictionary _series = []; + + public BillRun(MeterVaultDbContext db, AnalysisReader reader, CostAnalysisRequest request, string currency) + { + _db = db; + _reader = reader; + _request = request; + _period = request.Period; + _zone = reader.Zone; + _currency = currency; + _today = PeriodResolver.LocalDate(_period.Now, _zone); + } + + private bool WantsCategories => + _request.Scope.Kind == CostScopeKind.Category || (_request.Scope.Kind == CostScopeKind.Portfolio && _request.IncludeCategories); + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + await LoadAsync(WantsCategories, cancellationToken).ConfigureAwait(false); + if (ScopeFor() is not { } scope) + { + return Refused(EmptyPlan(), CostRefusal.UnknownScope); + } + + var plan = await PlanAsync(scope, cancellationToken).ConfigureAwait(false); + if (plan.Refused) + { + return Refused(plan, CostRefusal.TooManyPoints); + } + + _plan = plan; + _periodBucket = PeriodBucket.Of(_period); + _displayParts = CostCalculator.Parts(plan.Buckets); + _periodParts = CostCalculator.Parts([_periodBucket]); + await ReadQuantitiesAsync(scope, cancellationToken).ConfigureAwait(false); + await LoadServiceAsync(cancellationToken).ConfigureAwait(false); + + foreach (var group in scope.AllGroups()) + { + Price(group); + } + + return Compose(scope); + } + + /// What a scope has data for (D-19): its priced meters' coverage and its manual costs, without pricing. + public async Task AvailabilityAsync(CancellationToken cancellationToken) + { + await LoadAsync(WantsCategories, cancellationToken).ConfigureAwait(false); + if (ScopeFor() is not { } scope) + { + return CostAvailability.None; + } + + var metered = await _reader.QuantityAvailabilityAsync(_db, _catalog, scope.AvailabilityMeters, _period.Now, cancellationToken) + .ConfigureAwait(false); + return Availability(metered, scope.ManualInScope); + } + + // ------------------------------------------------------------------------------------------------ loading + + private async Task LoadAsync(bool categories, CancellationToken cancellationToken) + { + _catalog = await AnalysisCatalog.LoadAsync(_db, _zone, cancellationToken).ConfigureAwait(false); + + // One tariff load per request; ordered so that the book's deterministic tie-break never depends on the plan. + var tariffs = await _db.Tariffs.AsNoTracking().OrderBy(t => t.Id).ToListAsync(cancellationToken).ConfigureAwait(false); + _book = TariffBook.Create(tariffs, _currency); + _full = _catalog.Classify(_book.HasMeterScopedUnitPrice); + _virtualSources = _catalog.TotalsMeters.Where(m => m.IsVirtual).ToDictionary(m => m.Id, m => m.VirtualSources); + + _manual = await _db.ManualCosts.AsNoTracking().OrderBy(c => c.Id).ToListAsync(cancellationToken).ConfigureAwait(false); + if (categories) + { + _categories = await _db.CostCategories.AsNoTracking() + .Include(c => c.Members) + .OrderBy(c => c.Sort) + .ThenBy(c => c.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + } + + // ------------------------------------------------------------------------------------------------ scope → groups + + /// The scope as pricing groups, or null when its meter or category does not exist. + private ScopePlan? ScopeFor() + { + var scope = _request.Scope; + switch (scope.Kind) + { + case CostScopeKind.Portfolio: + { + var plan = new ScopePlan { AvailabilityMeters = [.. _full.BillItems.Order()], ManualInScope = [.. _manual] }; + foreach (var type in TypesWithMeters()) + { + plan.Types.Add((type, TypeGroup(type))); + } + + plan.Portfolio = new PricingGroup(); + if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Global, null)) + { + plan.Portfolio.Rows.Add(new StandingChargeKey(TariffScope.Global, null)); + } + + plan.Portfolio.Manual.AddRange(_manual.Where(c => c.MeterId is not { } id || _catalog.Find(id) is null)); + if (_request.IncludeCategories) + { + plan.Categories = PlanCategories(); + } + + return plan; + } + + case CostScopeKind.EnergyType: + { + var type = scope.Id!.Value; + var plan = new ScopePlan + { + AvailabilityMeters = [.. _full.ForType(type).Billing.Items.Order()], + ManualInScope = [.. _manual.Where(c => MeterType(c.MeterId) == type)], + }; + plan.Types.Add((type, TypeGroup(type))); + return plan; + } + + case CostScopeKind.Meter: + { + if (_catalog.Find(scope.Id!.Value) is not { } meter) + { + return null; + } + + var (group, info) = MeterGroup(meter); + return new ScopePlan + { + Single = group, + Meter = info, + AvailabilityMeters = [meter.Id], + ManualInScope = [.. group.Manual], + OwnSemantics = !meter.IsVirtual, + }; + } + + default: + { + if (_categories.Find(c => c.Id == scope.Id) is null) + { + return null; + } + + var categories = PlanCategories(); + var entry = categories.Entries[scope.Id!.Value]; + return new ScopePlan + { + Single = entry.Group, + CategoryEntry = entry, + Categories = categories, + AvailabilityMeters = entry.Cover.CoverMeterIds, + ManualInScope = [.. entry.Group.Manual], + }; + } + } + } + + private IEnumerable TypesWithMeters() => _catalog.Meters.Values.Select(m => m.EnergyTypeId).Distinct().Order(); + + private int? MeterType(int? meterId) => meterId is { } id && _catalog.Find(id) is { } meter ? meter.EnergyTypeId : null; + + /// A type's bill (D-34, D-35): its lines, its own standing charge (D-40), the manual costs of its meters (D-41). + private PricingGroup TypeGroup(int type) + { + var billing = _full.ForType(type).Billing; + var group = new PricingGroup { EnergyTypeId = type, Basis = billing.Basis }; + foreach (var line in billing.Lines) + { + group.Lines.AddRange(Expand(line, billing.SeparatelyBilled)); + } + + if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.EnergyType, type)) + { + group.Rows.Add(new StandingChargeKey(TariffScope.EnergyType, type)); + } + + group.Rows.AddRange(MeterFeeRows(type, group.Lines)); + group.Manual.AddRange(_manual.Where(c => MeterType(c.MeterId) == type)); + return group; + } + + /// + /// A-18: the meter-scoped standing charges of a type's physical meters that no line of + /// accrues — a PV or house meter behind the billed grid meter — each as a row of its own on its meter, so a meter fee + /// is charged once, on its meter (D-40), rather than dropped because its meter is not a bill line. + /// + private List MeterFeeRows(int type, IEnumerable lines) + { + var lined = lines.Select(l => l.MeterId).ToHashSet(); + return [.. _catalog.Meters.Values + .Where(m => m.EnergyTypeId == type && !m.IsVirtual && !lined.Contains(m.Id) + && _book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Meter, m.Id)) + .Select(m => m.Id) + .Order() + .Select(id => new StandingChargeKey(TariffScope.Meter, id))]; + } + + /// + /// A meter's own cost (D-39 for virtual meters): the line the bill prices it with, else a view at its unit price or + /// its feed-in credit; a virtual meter by its cost rule. Its manual costs go with it (D-41). + /// + private (PricingGroup Group, MeterCostInfo Info) MeterGroup(AnalysisMeter meter) + { + var group = new PricingGroup(); + group.Manual.AddRange(_manual.Where(c => c.MeterId == meter.Id)); + var line = _full.LineOf(meter.Id); + + if (!meter.IsVirtual) + { + var own = OwnLines(meter); + group.Lines.AddRange(own.Lines); + + // A meter the scope prices no quantity of (a generation meter) still carries its own fee (A-18). + if (own.Lines.Count == 0 && _book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Meter, meter.Id)) + { + group.Rows.Add(new StandingChargeKey(TariffScope.Meter, meter.Id)); + } + + return (group, new MeterCostInfo(meter.Id, own.Rule, own.OnBill, own.Reason, [])); + } + + if (meter.Formula is null) + { + return (group, Info(MeterCostRule.None, line is not null, MeterNotCostedReason.NotEvaluable)); + } + + var rule = meter.CostRule switch + { + VirtualCostRule.SourceCosts => MeterCostRule.SourceCosts, + VirtualCostRule.OwnQuantity => MeterCostRule.OwnQuantity, + _ => MeterCostRule.None, + }; + + // A stored source-costs rule over a nested difference is taken as none (A-15), and a generation result is never + // billed (D-34): say why rather than "no rule". + var notCosted = rule != MeterCostRule.None ? MeterNotCostedReason.None + : meter.Validation?.CostRuleProblem is not null ? MeterNotCostedReason.SourcesNotPureSum + : meter.Quantity.Kind == QuantityKind.Generation ? MeterNotCostedReason.Generation + : MeterNotCostedReason.NoCostRule; + + if (line is not null) + { + group.Lines.AddRange(Expand(line, SeparatelyBilledOf(meter.EnergyTypeId))); + IReadOnlyList billedSources = rule == MeterCostRule.SourceCosts + ? [.. group.Lines.Select(l => l.MeterId).Distinct().Order()] + : []; + return (group, new MeterCostInfo(meter.Id, rule, true, notCosted, billedSources)); + } + + if (rule == MeterCostRule.SourceCosts) + { + if (CostSourcesOf(meter.Id) is not { } sourceIds) + { + return (group, Info(MeterCostRule.None, false, MeterNotCostedReason.SourcesNotPureSum)); + } + + var lines = SourceLines(meter, [], []); + if (lines.Count == 0) + { + // D-39 adds the sources' own costs; generation and runtime sources have none (D-34). + return (group, Info(MeterCostRule.None, false, NotCostedReasonOf(sourceIds))); + } + + group.Lines.AddRange(lines); + return (group, new MeterCostInfo(meter.Id, rule, false, notCosted, [.. lines.Select(l => l.MeterId).Distinct().Order()])); + } + + if (rule == MeterCostRule.OwnQuantity) + { + group.Lines.Add(new LineSpec(meter.Id, meter.EnergyTypeId, BillLineKind.UnitPrice, meter.Quantity.Unit, [], null)); + } + + return (group, new MeterCostInfo(meter.Id, rule, false, notCosted, [])); + + MeterCostInfo Info(MeterCostRule costRule, bool onBill, MeterNotCostedReason reason = MeterNotCostedReason.None) => + new(meter.Id, costRule, onBill, reason, []); + } + + /// + /// What a physical meter's own scope prices: its line on the bill; otherwise a view by what it measures — its + /// consumption at its unit price, its export as a feed-in credit — or nothing, and why (generation and operating time + /// are never billed, D-34). + /// + private OwnCost OwnLines(AnalysisMeter meter) + { + if (_full.LineOf(meter.Id) is { } line) + { + return new OwnCost([.. Expand(line, SeparatelyBilledOf(meter.EnergyTypeId))], MeterCostRule.BillLine, true, MeterNotCostedReason.None); + } + + return meter.Quantity.Kind switch + { + QuantityKind.Consumption => new OwnCost( + [new LineSpec(meter.Id, meter.EnergyTypeId, BillLineKind.UnitPrice, meter.Quantity.Unit, [], null)], + MeterCostRule.UnitPriceView, false, MeterNotCostedReason.None), + QuantityKind.Export => new OwnCost( + [new LineSpec(meter.Id, meter.EnergyTypeId, BillLineKind.FeedIn, meter.Quantity.Unit, [], null)], + MeterCostRule.FeedInView, false, MeterNotCostedReason.None), + QuantityKind.Generation => new OwnCost([], MeterCostRule.None, false, MeterNotCostedReason.Generation), + QuantityKind.Runtime => new OwnCost([], MeterCostRule.None, false, MeterNotCostedReason.Runtime), + _ => new OwnCost([], MeterCostRule.None, false, MeterNotCostedReason.NoCostRule), + }; + } + + /// Why a sum whose sources price nothing has no cost: all generation, all operating time, or no rule at all. + private MeterNotCostedReason NotCostedReasonOf(IReadOnlyList sourceIds) + { + var kinds = sourceIds.Select(id => _catalog.Meters[id].Quantity.Kind).Distinct().ToList(); + return kinds switch + { + [QuantityKind.Generation] => MeterNotCostedReason.Generation, + [QuantityKind.Runtime] => MeterNotCostedReason.Runtime, + _ => MeterNotCostedReason.NoCostRule, + }; + } + + private IReadOnlyList SeparatelyBilledOf(int type) => _full.ForType(type).Billing.SeparatelyBilled; + + /// + /// A bill line as priced lines. A physical meter is its own line. A virtual meter counted by an override (D-23) is + /// priced by its cost rule (D-39): its own quantity, or its sources' metered costs — one line per source, each at + /// its own price, never the sum repriced; with the rule none it is left out and reported. + /// + private IEnumerable Expand(BillLine line, IReadOnlyList separately, bool report = true) + { + var meter = _catalog.Meters[line.MeterId]; + if (!meter.IsVirtual || meter.CostRule == VirtualCostRule.OwnQuantity) + { + return [new LineSpec(meter.Id, meter.EnergyTypeId, line.Kind, meter.Quantity.Unit, line.Deductions, null)]; + } + + if (meter.CostRule == VirtualCostRule.SourceCosts) + { + return SourceLines(meter, line.Deductions, separately); + } + + if (report) + { + _notCosted.Add(meter.Id); + } + + return []; + } + + /// + /// D-39 sourceCosts: each physical source priced the way its own scope prices it (A-15) — a consumption source + /// at its unit price (or its bill line), an export source as a feed-in credit, a generation or runtime source not at + /// all — once per source the formula's weights name (). A separately billed subsection the + /// bill takes out of the sum is taken out of the source it runs through. + /// + private List SourceLines(AnalysisMeter meter, IReadOnlyList deductions, IReadOnlyList separately) + { + var sources = CostSourcesOf(meter.Id) ?? []; + var perSource = sources.Select(_ => new List()).ToList(); + foreach (var deduction in deductions) + { + var through = separately.FirstOrDefault(s => s.MeterId == deduction.MeterId && s.SubtractFromMeterId == meter.Id)?.ThroughMeterId; + var index = through is { } t ? Math.Max(0, sources.ToList().IndexOf(t)) : 0; + if (perSource.Count == 0) + { + break; + } + + var factor = UnitFactor(deduction.MeterId, sources[index]) ?? deduction.UnitFactor; + perSource[index].Add(new BillDeduction(deduction.MeterId, factor)); + } + + var lines = new List(); + for (var i = 0; i < sources.Count; i++) + { + var own = OwnLines(_catalog.Meters[sources[i]]).Lines; + for (var j = 0; j < own.Count; j++) + { + lines.Add(own[j] with { Deductions = j == 0 ? [.. own[j].Deductions, .. perSource[i]] : own[j].Deductions, ForMeterId = meter.Id }); + } + } + + return lines; + } + + /// + /// The physical sources whose metered costs a sourceCosts meter adds (D-39): the meters its formula's weights + /// name — each once, so m1 + m1 - m1 + m2 is m1 and m2 — through nested pure sums. Null when a nested source + /// is not a pure sum: its sources' costs would include a subtrahend's (A-15). + /// + private List? CostSourcesOf(int virtualId) + { + var result = new List(); + var visiting = new HashSet(); + return Collect(virtualId) ? result : null; + + bool Collect(int id) + { + if (_catalog.Find(id) is not { } meter) + { + return true; + } + + if (!meter.IsVirtual) + { + result.Add(id); + return true; + } + + if (meter.Formula is not { IsPureSum: true, Coefficients: { } weights } || !visiting.Add(id)) + { + return false; + } + + foreach (var source in weights.Keys.Order()) + { + if (!Collect(source)) + { + return false; + } + } + + visiting.Remove(id); + return true; + } + } + + private IReadOnlyList SourcesOf(int virtualId) => + [.. (_virtualSources.GetValueOrDefault(virtualId) ?? []).Where(id => _catalog.Find(id) is { IsVirtual: false })]; + + private double? UnitFactor(int from, int to) + { + var fromUnit = _catalog.Meters[from].Quantity.Unit; + var toUnit = _catalog.Meters[to].Quantity.Unit; + return Units.AreSame(fromUnit, toUnit) ? 1d : Units.ConversionFactor(fromUnit, toUnit); + } + + // ------------------------------------------------------------------------------------------------ categories (D-42) + + /// + /// Every category's cover, the parts of the bill each holds, which categories are slices of the bill and which are + /// overlapping views, and the composition's groups. + /// + private CategoryPlan PlanCategories() + { + var plan = new CategoryPlan(); + var billRows = BillRows(); + var members = _categories.ToDictionary(c => c.Id, ExpandMembers); + var covers = _categories.Select(c => CategoryCover.Compute(_full, c.Id, members[c.Id])).ToList(); + var report = CategoryCover.CheckOverlap(_full, covers); + + var manualClaims = _manual.ToDictionary(c => c.Id, c => ClaimsOf(c, members)); + var rowClaims = billRows.ToDictionary(r => r, r => _categories.Where(c => HoldsRow(c, r, members[c.Id])).Select(c => c.Id).ToList()); + + // A slice of the bill shares nothing with another slice: two line-disjoint categories claiming the same manual + // cost or standing charge would add it twice, so both become views. + var views = new HashSet(report.OverlappingViewIds); + var candidates = _categories.Select(c => c.Id).Where(id => !views.Contains(id)).ToHashSet(); + foreach (var claimants in manualClaims.Values.Concat(rowClaims.Values)) + { + var slices = claimants.Where(candidates.Contains).ToList(); + if (slices.Count > 1) + { + views.UnionWith(slices); + } + } + + plan.Overlaps = Overlaps(report, manualClaims, rowClaims); + var disjoint = _categories.Select(c => c.Id).Where(id => !views.Contains(id)).ToHashSet(); + + foreach (var (category, cover) in _categories.Zip(covers)) + { + var group = new PricingGroup(); + var separately = cover.SeparatelyBilled; + foreach (var line in cover.Lines) + { + group.Lines.AddRange(Expand(line, cover.LiesOutsideBill ? separately : SeparatelyBilledOf(_catalog.Meters[line.MeterId].EnergyTypeId))); + } + + // A meter's own fee accrues on its line where the category prices one (A-18); only a meter without one needs its row. + group.Rows.AddRange(rowClaims + .Where(r => r.Value.Contains(category.Id)) + .Select(r => r.Key) + .Where(r => r.Scope != TariffScope.Meter || !group.Lines.Exists(l => l.MeterId == r.ScopeId))); + group.Manual.AddRange(_manual.Where(c => manualClaims[c.Id].Contains(category.Id))); + plan.Entries[category.Id] = new CategoryEntry(category, cover, group, views.Contains(category.Id)); + } + + // Uncategorized: the bill lines and manual costs no slice holds; standing charges no slice holds are rows of their own. + var categorized = disjoint.SelectMany(id => plan.Entries[id].Cover.CoverMeterIds).ToHashSet(); + plan.UncategorizedMeterIds = [.. _full.BillItems.Where(id => !categorized.Contains(id)).Order()]; + foreach (var id in plan.UncategorizedMeterIds) + { + plan.Uncategorized.Lines.AddRange(Expand(_full.LineOf(id)!, SeparatelyBilledOf(_catalog.Meters[id].EnergyTypeId))); + } + + plan.Uncategorized.Manual.AddRange(_manual.Where(c => !manualClaims[c.Id].Exists(disjoint.Contains))); + foreach (var row in billRows.Where(r => !rowClaims[r].Exists(disjoint.Contains))) + { + var group = new PricingGroup(); + group.Rows.Add(row); + plan.RowSlices.Add((row, group)); + } + + plan.Disjoint = [.. _categories.Where(c => disjoint.Contains(c.Id)).Select(c => c.Id)]; + return plan; + } + + /// + /// The standing-charge rows of the whole bill: each type with meters and a base price, the meter fees no bill line + /// accrues (A-18, as adds them), and the global one. + /// + private List BillRows() + { + var rows = new List(); + foreach (var type in TypesWithMeters()) + { + if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.EnergyType, type)) + { + rows.Add(new StandingChargeKey(TariffScope.EnergyType, type)); + } + + var lines = _full.ForType(type).Billing.Lines.SelectMany(l => Expand(l, SeparatelyBilledOf(type), report: false)); + rows.AddRange(MeterFeeRows(type, lines)); + } + + if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Global, null)) + { + rows.Add(new StandingChargeKey(TariffScope.Global, null)); + } + + return rows; + } + + /// A category's members with type members expanded to every meter of the type, known meters only, ascending. + private IReadOnlyList ExpandMembers(CostCategory category) + { + var ids = new SortedSet(); + foreach (var member in category.Members) + { + if (member.MeterId is { } meterId && _catalog.Find(meterId) is not null) + { + ids.Add(meterId); + } + + if (member.EnergyTypeId is { } type) + { + ids.UnionWith(_catalog.Meters.Values.Where(m => m.EnergyTypeId == type).Select(m => m.Id)); + } + } + + return [.. ids]; + } + + /// + /// The categories a manual cost belongs to (D-41): its own category, else every category holding its meter. + /// + private List ClaimsOf(ManualCost cost, Dictionary> members) + { + if (cost.CategoryId is { } categoryId) + { + return members.ContainsKey(categoryId) ? [categoryId] : []; + } + + return cost.MeterId is { } meterId + ? [.. _categories.Where(c => members[c.Id].Contains(meterId)).Select(c => c.Id)] + : []; + } + + /// + /// D-42: a type's standing charge joins a category that holds the whole type — the type itself, or every physical + /// meter of it; the global one a category that holds every billed meter. + /// + private bool HoldsRow(CostCategory category, StandingChargeKey row, IReadOnlyList members) + { + var set = members.ToHashSet(); + if (row.Scope == TariffScope.Global) + { + return _full.BillItems.Count > 0 && _full.BillItems.All(set.Contains); + } + + if (row.Scope == TariffScope.Meter) + { + // A meter's own fee goes with its meter (D-40, D-42). + return set.Contains(row.ScopeId!.Value); + } + + var type = row.ScopeId!.Value; + if (category.Members.Any(m => m.EnergyTypeId == type)) + { + return true; + } + + var physical = _catalog.Meters.Values.Where(m => m.EnergyTypeId == type && !m.IsVirtual).Select(m => m.Id).ToList(); + return physical.Count > 0 && physical.All(set.Contains); + } + + private static List Overlaps( + CategoryOverlapReport report, + Dictionary> manualClaims, + Dictionary> rowClaims) + { + var pairs = new SortedDictionary<(int, int), (SortedSet Meters, SortedSet Manual, List Rows)>(); + + (SortedSet Meters, SortedSet Manual, List Rows) Pair(int a, int b) + { + var key = a < b ? (a, b) : (b, a); + if (!pairs.TryGetValue(key, out var pair)) + { + pairs[key] = pair = ([], [], []); + } + + return pair; + } + + foreach (var overlap in report.Overlaps) + { + Pair(overlap.CategoryId, overlap.OtherCategoryId).Meters.UnionWith(overlap.SharedMeterIds); + } + + foreach (var (manualId, claimants) in manualClaims) + { + ForEachPair(claimants, (a, b) => Pair(a, b).Manual.Add(manualId)); + } + + foreach (var (row, claimants) in rowClaims) + { + ForEachPair(claimants, (a, b) => Pair(a, b).Rows.Add(row)); + } + + return [.. pairs.Select(p => new CostCategoryOverlap(p.Key.Item1, p.Key.Item2, [.. p.Value.Meters], [.. p.Value.Manual], p.Value.Rows))]; + + static void ForEachPair(List ids, Action action) + { + for (var i = 0; i < ids.Count; i++) + { + for (var j = i + 1; j < ids.Count; j++) + { + action(ids[i], ids[j]); + } + } + } + } + + // ------------------------------------------------------------------------------------------------ plan and quantities + + /// + /// The buckets (D-05): the caller's plan, the size asked for, or auto from the coverage of the priced meters. A line + /// without any tariff has no cost to chart, so its resolution (a tank's months-long dipstick intervals) does not + /// coarsen the chart; only when nothing has a tariff do all lines decide. + /// + private async Task PlanAsync(ScopePlan scope, CancellationToken cancellationToken) + { + if (_request.Plan is { } given) + { + return given; + } + + var lines = scope.AllGroups().SelectMany(g => g.Lines).ToList(); + var withPrice = lines.Where(HasAnyPrice).ToList(); + var priced = (withPrice.Count > 0 ? withPrice : lines).Select(l => l.MeterId).Distinct().Order().ToList(); + if (_request.Bucket != BucketSize.Auto || priced.Count == 0) + { + return BucketPlanner.Plan(_period, _request.Bucket, maxPoints: _request.MaxPoints); + } + + var request = new AnalysisRequest(AnalysisScope.ForMeters(priced), _period) + { + MaxPoints = _request.MaxPoints, + MaxSeries = int.MaxValue, + QuantitiesOnly = true, + }; + return await _reader.PlanAsync(_db, _catalog, request, cancellationToken).ConfigureAwait(false); + } + + private bool HasAnyPrice(LineSpec line) => line.Kind switch + { + BillLineKind.FeedIn => _book.HasAny(TariffComponent.FeedIn, line.MeterId, line.EnergyTypeId), + BillLineKind.OwnPrice => _book.HasAnyInScope(TariffComponent.UnitPrice, TariffScope.Meter, line.MeterId), + _ => _book.HasAny(TariffComponent.UnitPrice, line.MeterId, line.EnergyTypeId), + }; + + /// + /// Reads every meter the groups price (with the subsections they deduct, and the meters the availability is taken + /// from) per local-month part (D-36): once for the buckets' parts, and — only when they differ — once for the + /// period's own months, which the total is priced from. + /// + private async Task ReadQuantitiesAsync(ScopePlan scope, CancellationToken cancellationToken) + { + var lines = scope.AllGroups().SelectMany(g => g.Lines).ToList(); + List ids = + [ + .. lines.Select(l => l.MeterId) + .Concat(lines.SelectMany(l => l.Deductions.Select(d => d.MeterId))) + .Concat(lines.SelectMany(l => BasisCheckOf(l)?.UseMeterIds ?? [])) + .Concat(scope.AvailabilityMeters) + .Where(id => _catalog.Find(id) is not null) + .Distinct() + .Order(), + ]; + if (ids.Count == 0) + { + return; + } + + var contributors = !scope.OwnSemantics; + var display = await ReadPartsAsync(ids, _displayParts, _plan.Buckets, _plan.Size, contributors, cancellationToken).ConfigureAwait(false); + _series = display; + _displayValues = display.ToDictionary(p => p.Key, p => p.Value.Values); + + if (SameParts()) + { + _periodValues = _displayValues; + } + else + { + var period = await ReadPartsAsync(ids, _periodParts, [_periodBucket], _periodBucket.Size, contributors, cancellationToken).ConfigureAwait(false); + _periodValues = period.ToDictionary(p => p.Key, p => p.Value.Values); + } + + // A-16: where a priced line's part is unresolved inside a bucket of several months, read the bucket whole too. + List spanned = + [ + .. lines.Where(HasAnyPrice) + .SelectMany(l => l.Deductions.Select(d => d.MeterId).Prepend(l.MeterId)) + .Where(id => _catalog.Find(id) is not null) + .Distinct() + .Order(), + ]; + if (NeedsSpans(_displayParts, _displayValues, spanned)) + { + _displaySpans = await ReadSpansAsync(spanned, _plan.Buckets, _plan.Size, contributors, cancellationToken).ConfigureAwait(false); + } + + if (NeedsSpans(_periodParts, _periodValues, spanned)) + { + _periodSpans = await ReadSpansAsync(spanned, [_periodBucket], _periodBucket.Size, contributors, cancellationToken).ConfigureAwait(false); + } + } + + /// True when a meter's part is unresolved inside a bucket that has several parts (A-16). + private static bool NeedsSpans(IReadOnlyList parts, Dictionary> values, IReadOnlyList ids) + { + var multi = parts.GroupBy(p => p.BucketIndex).Where(g => g.Count() > 1).Select(g => g.Key).ToHashSet(); + if (multi.Count == 0) + { + return false; + } + + return ids.Any(id => values.GetValueOrDefault(id) is { } series + && parts.Select((part, index) => (part, index)).Any(p => multi.Contains(p.part.BucketIndex) && series[p.index].Status == BucketStatus.Unresolved)); + } + + /// The meters' values over whole buckets, one per bucket (A-16). + private async Task>> ReadSpansAsync( + IReadOnlyList ids, IReadOnlyList buckets, BucketSize size, bool contributors, CancellationToken cancellationToken) + { + List whole = [.. buckets.Select((b, i) => new CostPart(i, b.FirstDay, b.EndDay))]; + var read = await ReadPartsAsync(ids, whole, buckets, size, contributors, cancellationToken).ConfigureAwait(false); + return read.ToDictionary(p => p.Key, p => p.Value.Values); + } + + private async Task> ReadPartsAsync( + IReadOnlyList ids, + IReadOnlyList parts, + IReadOnlyList buckets, + BucketSize size, + bool contributors, + CancellationToken cancellationToken) + { + List partBuckets = [.. parts.Select(p => PartBucket(p, buckets[p.BucketIndex]))]; + var request = new AnalysisRequest(AnalysisScope.ForMeters(ids), _period) + { + Plan = new BucketPlan(size, size, partBuckets, partBuckets.Count, Refused: false, Suggested: null), + MaxSeries = int.MaxValue, + AsContributors = contributors, + QuantitiesOnly = true, + }; + + var result = await _reader.ReadAsync(_db, _catalog, request, cancellationToken).ConfigureAwait(false); + foreach (var problem in result.Problems) + { + if (!_quantityProblems.Exists(p => p.Kind == problem.Kind && p.MeterId == problem.MeterId && p.Virtual?.Kind == problem.Virtual?.Kind)) + { + _quantityProblems.Add(problem); + } + } + + return result.Series.Where(s => s.MeterId is not null).ToDictionary(s => s.MeterId!.Value); + } + + /// + /// A part as a bucket the reader sums: its local days, clipped to its bucket's instants (the last part of a to-date + /// bucket ends at now), with its bucket's size, so it resolves exactly as its bucket would (D-14). + /// + private AnalysisBucket PartBucket(CostPart part, AnalysisBucket bucket) + { + var start = GapAttribution.LocalMidnight(part.FirstDay, _zone); + var end = GapAttribution.LocalMidnight(part.EndDay, _zone); + var from = start > bucket.From ? start : bucket.From; + var to = end < bucket.To ? end : bucket.To; + return new AnalysisBucket(part.FirstDay, part.EndDay, from, to < from ? from : to, bucket.Size); + } + + /// + /// True when the buckets' parts are the period's months, evaluated alike: the same days, and sizes that resolve + /// them the same way (a month and a year both resolve by month). + /// + private bool SameParts() + { + if (_displayParts.Count != _periodParts.Count) + { + return false; + } + + static bool Monthly(BucketSize size) => size is BucketSize.Month or BucketSize.Year; + var sizesAgree = _plan.Size == _periodBucket.Size || (Monthly(_plan.Size) && Monthly(_periodBucket.Size)); + return sizesAgree && _displayParts.Zip(_periodParts).All(p => p.First.FirstDay == p.Second.FirstDay && p.First.EndDay == p.Second.EndDay); + } + + /// + /// The first data day of every physical meter, for standing-charge service periods (D-40) — read only when there is + /// a base price to accrue. + /// + private async Task LoadServiceAsync(CancellationToken cancellationToken) + { + if (!_book.Tariffs.Any(t => t.Component == TariffComponent.BasePrice)) + { + return; + } + + var ids = _catalog.Meters.Values.Where(m => !m.IsVirtual).Select(m => m.Id).ToList(); + if (ids.Count == 0) + { + return; + } + + await _db.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var runs = await AnalysisQueries.CoverageAsync(_db.Database.GetDbConnection(), ids, cancellationToken).ConfigureAwait(false); + foreach (var id in ids) + { + _firstData[id] = AvailableRange.OfRuns(runs.GetValueOrDefault(id) ?? [], _period.Now, _zone)?.FirstDay; + } + } + + private ServicePeriod? MeterService(int meterId) + { + if (_catalog.Find(meterId) is not { } meter) + { + return null; + } + + return meter.IsVirtual + ? ServicePeriod.Span(SourcesOf(meterId).Distinct().Select(MeterService)) + : ServicePeriod.ForMeter(meter.Meter.InstalledAt, meter.Meter.RetiredAt, _firstData.GetValueOrDefault(meterId)); + } + + private ServicePeriod? RowService(StandingChargeKey row) => + row.Scope == TariffScope.Meter + ? MeterService(row.ScopeId!.Value) + : ServicePeriod.Span(_catalog.Meters.Values + .Where(m => !m.IsVirtual && (row.Scope == TariffScope.Global || m.EnergyTypeId == row.ScopeId)) + .Select(m => MeterService(m.Id))); + + // ------------------------------------------------------------------------------------------------ pricing + + /// Prices a group per bucket and over the period's own months. + private void Price(PricingGroup group) + { + var rows = group.Rows + .Select(r => new StandingChargeScope(r.Scope, r.ScopeId, RowService(r))) + .ToList(); + var bucketLines = group.Lines.Select(l => Line(l, _displayParts, _displayValues, _plan.Buckets, _displaySpans, group.BasisGaps)).ToList(); + var totalLines = group.Lines.Select(l => Line(l, _periodParts, _periodValues, [_periodBucket], _periodSpans, group.BasisGaps)).ToList(); + + group.ByBucket = CostCalculator.Calculate(new CostRequest(_plan.Buckets, _today, _book, bucketLines, rows, group.Manual)); + group.OverPeriod = CostCalculator.Calculate(new CostRequest([_periodBucket], _today, _book, totalLines, rows, group.Manual)); + foreach (var line in group.ByBucket.Lines.Concat(group.OverPeriod.Lines).Where(l => l.MonthsWithPriceChangeInsideInterval.Count > 0)) + { + Months(group.PriceChanges, line.MeterId).UnionWith(line.MonthsWithPriceChangeInsideInterval); + } + + for (var i = 0; i < group.Lines.Count; i++) + { + var spec = group.Lines[i]; + var buckets = group.ByBucket.Lines[i]; + var total = group.OverPeriod.Lines[i]; + var perBucket = new double?[_plan.Buckets.Count]; + for (var p = 0; p < _displayParts.Count; p++) + { + if (bucketLines[i].Quantities[p] is { IsKnown: true, Amount: { } amount }) + { + var index = _displayParts[p].BucketIndex; + perBucket[index] = (perBucket[index] ?? 0) + amount; + } + } + + var known = totalLines[i].Quantities.Where(q => q.IsKnown).Select(q => q.Amount!.Value).ToList(); + group.Figures.Add(new CostLineFigure( + spec.MeterId, + _catalog.Meters[spec.MeterId].Name, + spec.EnergyTypeId, + spec.Kind, + spec.Unit, + buckets.Buckets, + total.Total, + perBucket, + known.Count > 0 ? known.Sum() : null) + { + Deductions = spec.Deductions, + MonthsWithoutOwnPrice = total.MonthsWithoutOwnPrice, + ForMeterId = spec.ForMeterId, + }); + } + + foreach (var (buckets, total) in group.ByBucket.StandingCharges.Zip(group.OverPeriod.StandingCharges)) + { + group.RowFigures.Add(new StandingChargeFigure( + buckets.Scope, buckets.ScopeId, buckets.Buckets, total.Total, RowService(new StandingChargeKey(buckets.Scope, buckets.ScopeId)))); + } + } + + /// + /// A line's net quantity per part (D-35): its meter's value less the subsections billed on their own that month. A + /// month the type's billing basis has no meter in service for is unknown, not the grid meter's zero (A-17). Buckets + /// read whole (A-16) give the line its spans. + /// + private CostLine Line( + LineSpec spec, + IReadOnlyList parts, + Dictionary> values, + IReadOnlyList buckets, + Dictionary> spanValues, + Dictionary> basisGaps) + { + var own = values.GetValueOrDefault(spec.MeterId); + var basis = BasisCheckOf(spec); + var quantities = new List(parts.Count); + var gapBuckets = new HashSet(); + for (var p = 0; p < parts.Count; p++) + { + var part = parts[p]; + if (basis is not null && IsBasisGap(basis, part, p, values)) + { + quantities.Add(CostQuantity.Unknown(part, BucketStatus.Missing)); + Months(basisGaps, spec.MeterId).Add(part.Month); + gapBuckets.Add(part.BucketIndex); + continue; + } + + var value = own?[p] ?? BucketValue.Missing(); + List<(BucketValue Value, double Factor)> deducted = []; + foreach (var deduction in spec.Deductions) + { + if (_book.HasOwnUnitPrice(deduction.MeterId, part.Month)) + { + deducted.Add((values.GetValueOrDefault(deduction.MeterId)?[p] ?? BucketValue.Missing(), deduction.UnitFactor)); + } + } + + quantities.Add(Net(part, value, deducted)); + } + + var service = _book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Meter, spec.MeterId) ? MeterService(spec.MeterId) : null; + return new CostLine(spec.MeterId, spec.EnergyTypeId, spec.Kind, spec.Unit, quantities, service) + { + Spans = Spans(spec, parts, buckets, spanValues, gapBuckets), + }; + } + + /// + /// A line's quantity over each bucket of several parts that was read whole (A-16), net of the subsections billed on + /// their own — only where the same subsections are taken out in every month of the bucket — and never over a bucket + /// holding a basis gap. + /// + private List? Spans( + LineSpec spec, + IReadOnlyList parts, + IReadOnlyList buckets, + Dictionary> spanValues, + HashSet gapBuckets) + { + if (spanValues.GetValueOrDefault(spec.MeterId) is not { } own) + { + return null; + } + + var spans = new List(); + foreach (var bucket in parts.GroupBy(p => p.BucketIndex).Where(g => g.Count() > 1)) + { + var index = bucket.Key; + if (gapBuckets.Contains(index) || index >= own.Count) + { + continue; + } + + var applied = bucket + .Select(part => spec.Deductions.Where(d => _book.HasOwnUnitPrice(d.MeterId, part.Month)).Select(d => d.MeterId).ToHashSet()) + .ToList(); + if (!applied.TrueForAll(set => set.SetEquals(applied[0]))) + { + continue; + } + + var deducted = spec.Deductions + .Where(d => applied[0].Contains(d.MeterId)) + .Select(d => (spanValues.GetValueOrDefault(d.MeterId)?[index] ?? BucketValue.Missing(), d.UnitFactor)) + .ToList(); + var whole = new CostPart(index, buckets[index].FirstDay, buckets[index].EndDay); + var net = Net(whole, own[index], deducted); + spans.Add(new CostSpan(index, net.Amount, net.Availability)); + } + + return spans.Count > 0 ? spans : null; + } + + /// + /// A-17: for a grid-import line of a type billed on its grid import, the grid meters whose service decides the basis, + /// and the use meters whose data shows a month the basis misses. Null for any other line, and when no billed grid + /// meter has an install or retire date (then it covers every month). + /// + private BasisCheck? BasisCheckOf(LineSpec spec) + { + if (spec.Kind != BillLineKind.UnitPrice || spec.ForMeterId is not null) + { + return null; + } + + var totals = _full.ForType(spec.EnergyTypeId); + var billing = totals.Billing; + if (billing.Basis != BillingBasis.GridImport || !billing.BilledMeterIds.Contains(spec.MeterId)) + { + return null; + } + + var grids = billing.BilledMeterIds.Select(id => _catalog.Meters[id].Meter).ToList(); + var use = totals.MetersIn(TotalsMeasure.Use).Where(id => _catalog.Find(id) is { IsVirtual: false }).ToList(); + if (use.Count == 0 || grids.TrueForAll(m => m.InstalledAt is null && m.RetiredAt is null)) + { + return null; + } + + return new BasisCheck([.. grids.Select(m => (m.InstalledAt ?? DateOnly.MinValue, m.RetiredAt ?? DateOnly.MaxValue))], use); + } + + /// + /// True when some day of has no billed grid meter in service while a use meter in service + /// that month measured something (A-17): the grid meter's known zero would pass the use off as free. + /// + private bool IsBasisGap(BasisCheck basis, CostPart part, int index, Dictionary> values) + { + var covered = true; + for (var day = part.FirstDay; day < part.EndDay && covered; day = day.AddDays(1)) + { + covered = basis.GridService.Exists(w => w.First <= day && day <= w.Last); + } + + if (covered) + { + return false; + } + + return basis.UseMeterIds.Any(id => + { + var meter = _catalog.Meters[id].Meter; + var inService = (meter.InstalledAt ?? DateOnly.MinValue) < part.EndDay && (meter.RetiredAt ?? DateOnly.MaxValue) >= part.FirstDay; + var value = values.GetValueOrDefault(id)?[index]; + return inService && value is not null && value.Status != BucketStatus.Missing + && !(value.Status == BucketStatus.Available && value.Value is { } amount && Math.Abs(amount) <= ProvenanceRules.Epsilon); + }); + } + + private static SortedSet Months(Dictionary> map, int meterId) + { + if (!map.TryGetValue(meterId, out var months)) + { + map[meterId] = months = []; + } + + return months; + } + + /// + /// A meter's value less deducted values: unknown as soon as any of them is (the strictest status wins, as for a + /// virtual difference, D-27), partial when any is partial. + /// + internal static CostQuantity Net(CostPart part, BucketValue value, IReadOnlyList<(BucketValue Value, double Factor)> deducted) + { + if (deducted.Count == 0) + { + return value.Value is { } plain && value.Status is BucketStatus.Available or BucketStatus.Partial + ? CostQuantity.Known(part, plain, value.Status) + : CostQuantity.Unknown(part, UnknownStatus(value.Status)); + } + + var all = deducted.Select(d => d.Value).Prepend(value).ToList(); + foreach (var status in (ReadOnlySpan)[BucketStatus.Pending, BucketStatus.Invalid, BucketStatus.Unresolved]) + { + if (all.Exists(v => v.Status == status)) + { + return CostQuantity.Unknown(part, status); + } + } + + if (all.Exists(v => v.Value is null || v.Status is not (BucketStatus.Available or BucketStatus.Partial))) + { + return CostQuantity.Unknown(part, BucketStatus.Missing); + } + + var net = value.Value!.Value - deducted.Sum(d => d.Value.Value!.Value * d.Factor); + return CostQuantity.Known(part, net, all.Exists(v => v.Status == BucketStatus.Partial) ? BucketStatus.Partial : BucketStatus.Available); + } + + private static BucketStatus UnknownStatus(BucketStatus status) => + status is BucketStatus.Available or BucketStatus.Partial ? BucketStatus.Missing : status; + + // ------------------------------------------------------------------------------------------------ result + + private CostAnalysis Compose(ScopePlan scope) + { + var groups = scope.Groups().ToList(); + var buckets = Enumerable.Range(0, _plan.Buckets.Count) + .Select(b => CostAmount.Sum(groups.Select(g => g.ByBucket.Totals[b]))) + .ToList(); + var total = CostAmount.Sum(groups.Select(g => g.OverPeriod.Total)); + + var manual = new ManualCostFigure( + [.. groups.SelectMany(g => g.ByBucket.ManualCosts.Bookings).OrderBy(b => b.Day).ThenBy(b => b.ManualCostId)], + AfterToday(groups), + [.. Enumerable.Range(0, _plan.Buckets.Count).Select(b => CostAmount.Sum(groups.Select(g => g.ByBucket.ManualCosts.Buckets[b])))], + CostAmount.Sum(groups.Select(g => g.OverPeriod.ManualCosts.Total))); + + var warnings = Warnings(groups); + var result = new CostAnalysis( + _request, + _plan, + _book.Currency, + buckets, + total, + [.. groups.SelectMany(g => g.Figures)], + [.. groups.SelectMany(g => g.RowFigures)], + manual, + Availability(MeteredAvailability(scope), scope.ManualInScope), + Attention(scope, total, manual, warnings)) + { + Warnings = warnings, + QuantityProblems = _quantityProblems, + EnergyTypes = [.. scope.Types.Select(t => new EnergyTypeCostFigure( + t.EnergyTypeId, t.Group.Basis, t.Group.ByBucket.Totals, t.Group.OverPeriod.Total, [.. t.Group.Lines.Select(l => l.MeterId).Distinct()]))], + Meter = scope.Meter, + }; + + if (scope.Categories is { } categories) + { + if (scope.CategoryEntry is { } entry) + { + result = result with { Category = Figure(entry, categories) }; + } + else + { + result = result with { Composition = Composition(categories) }; + } + } + + return result; + } + + private CategoryComposition Composition(CategoryPlan plan) + { + var slices = new List(); + foreach (var id in plan.Disjoint) + { + var entry = plan.Entries[id]; + slices.Add(new CompositionSlice(CompositionSliceKind.Category, id, null, entry.Group.ByBucket.Totals, entry.Group.OverPeriod.Total) + { + MeterIds = entry.Cover.CoverMeterIds, + ManualCostIds = [.. entry.Group.Manual.Select(c => c.Id)], + }); + } + + slices.Add(new CompositionSlice(CompositionSliceKind.Uncategorized, null, null, plan.Uncategorized.ByBucket.Totals, plan.Uncategorized.OverPeriod.Total) + { + MeterIds = plan.UncategorizedMeterIds, + ManualCostIds = [.. plan.Uncategorized.Manual.Select(c => c.Id)], + }); + + foreach (var (row, group) in plan.RowSlices) + { + slices.Add(new CompositionSlice(CompositionSliceKind.StandingCharge, null, row, group.ByBucket.Totals, group.OverPeriod.Total)); + } + + return new CategoryComposition( + slices, + [.. _categories.Select(c => Figure(plan.Entries[c.Id], plan))], + plan.Overlaps, + [.. Enumerable.Range(0, _plan.Buckets.Count).Select(b => CostAmount.Sum(slices.Select(s => s.Buckets[b])))], + CostAmount.Sum(slices.Select(s => s.Total))); + } + + private static CategoryCostFigure Figure(CategoryEntry entry, CategoryPlan plan) + { + var id = entry.Category.Id; + return new CategoryCostFigure( + id, + entry.Category.Name, + entry.Category.ColorHex, + entry.Category.Sort, + entry.Cover, + entry.Group.ByBucket.Totals, + entry.Group.OverPeriod.Total) + { + IsOverlappingView = entry.IsView, + OverlapsWith = [.. plan.Overlaps + .Where(o => o.CategoryId == id || o.OtherCategoryId == id) + .Select(o => o.CategoryId == id ? o.OtherCategoryId : o.CategoryId) + .Distinct() + .Order()], + ManualCostIds = [.. entry.Group.Manual.Select(c => c.Id)], + StandingCharges = entry.Group.Rows, + }; + } + + /// + /// The manual costs of the figure that start after today but inside the range the period names (D-41, compare + /// D-04): the buckets stop at now, so they are in none, and are reported rather than silently left out. + /// + private List AfterToday(IEnumerable groups) + { + var last = _period.Preset == PeriodPreset.AllHistory ? DateOnly.MaxValue : _period.NominalLastDay(); + return + [ + .. groups.SelectMany(g => g.Manual) + .Where(c => c.PeriodStart > _today && c.PeriodStart >= _period.FirstDay && c.PeriodStart <= last) + .Select(c => c.Id) + .Concat(groups.SelectMany(g => g.OverPeriod.ManualCosts.AfterTodayIds)) + .Distinct() + .Order(), + ]; + } + + /// The coverage of the scope's priced meters, from the series the quantities were read with. + private AvailableRange? MeteredAvailability(ScopePlan scope) => + AvailableRange.Union(scope.AvailabilityMeters.Select(id => _series.GetValueOrDefault(id)?.Availability), _zone); + + /// D-19: the priced meters' coverage and the manual costs' start days up to today, and the latest month of either. + private CostAvailability Availability(AvailableRange? metered, IReadOnlyList manual) + { + var days = manual.Where(c => c.PeriodStart <= _today).Select(c => c.PeriodStart).ToList(); + AvailableRange? manualRange = null; + if (days.Count > 0) + { + var from = GapAttribution.LocalMidnight(days.Min(), _zone); + var to = GapAttribution.LocalMidnight(days.Max().AddDays(1), _zone); + manualRange = AvailableRange.Of(from, to < _period.Now ? to : _period.Now, _zone) ?? AvailableRange.Of(from, to, _zone); + } + + var range = AvailableRange.Union([metered, manualRange], _zone); + LatestPeriod? latest = null; + if (range is not null) + { + var month = range.LatestMonth; + var byMeters = metered?.LatestMonth == month; + var byManual = manualRange?.LatestMonth == month; + latest = new LatestPeriod(month, byMeters && byManual ? LatestPeriodBasis.Both : byManual ? LatestPeriodBasis.Manual : LatestPeriodBasis.Meters); + } + + return new CostAvailability(metered, manualRange, range, latest); + } + + /// Tariffs applied with an unchecked unit, each once with the first month it priced. + private static List Warnings(IEnumerable groups) => + [.. groups + .SelectMany(g => g.OverPeriod.Warnings) + .GroupBy(w => (w.TariffId, w.Component, w.Issue, w.MeterId)) + .Select(g => g.MinBy(w => w.FirstMonth)!) + .OrderBy(w => w.FirstMonth) + .ThenBy(w => w.TariffId) + .ThenBy(w => w.MeterId ?? int.MaxValue)]; + + /// The cost attention items (D-53) of the result. + private List Attention(ScopePlan scope, CostAmount total, ManualCostFigure manual, IReadOnlyList warnings) + { + var items = new List(); + items.AddRange(total.MissingPrices.Select(m => new CostAttention(CostAttentionKind.MissingPrice, m.MeterId) { Price = m })); + items.AddRange(warnings.Select(w => new CostAttention(CostAttentionKind.UnverifiedTariffUnit, w.MeterId) { Warning = w })); + + if (manual.AfterTodayIds.Count > 0) + { + items.Add(new CostAttention(CostAttentionKind.ManualCostAfterToday, null) { ManualCostIds = manual.AfterTodayIds }); + } + + var foreign = manual.Bookings.Where(b => b.CurrencyMismatch).Select(b => b.ManualCostId).Distinct().Order().ToList(); + if (foreign.Count > 0) + { + items.Add(new CostAttention(CostAttentionKind.ManualCostCurrency, null) { ManualCostIds = foreign }); + } + + items.AddRange(_notCosted.Order().Select(id => new CostAttention(CostAttentionKind.VirtualNotCosted, id))); + + var groups = scope.Groups().ToList(); + foreach (var (kind, map) in new[] + { + (CostAttentionKind.PriceChangeInsideInterval, Merge(groups.Select(g => g.PriceChanges))), + (CostAttentionKind.BillingBasisGap, Merge(groups.Select(g => g.BasisGaps))), + }) + { + items.AddRange(map.OrderBy(m => m.Key).Select(m => new CostAttention(kind, m.Key) { FirstMonth = m.Value.Min, LastMonth = m.Value.Max })); + } + + items.AddRange(_full.Problems + .Where(p => p.Kind is TotalsProblemKind.UnusedMeterPrice or TotalsProblemKind.SeparateBillingUnitMismatch && InScope(scope, p.MeterId)) + .Select(p => new CostAttention(CostAttentionKind.BillingConfiguration, p.MeterId) { Totals = p })); + + // A category whose members give it no line — calculated views, generation, operating hours (D-39, D-42) — reads + // empty; say why rather than let it pass for a category without data (A-22). + IEnumerable entries = scope.CategoryEntry is { } own + ? [own] + : scope.Categories?.Entries.Values.OrderBy(e => e.Category.Sort).ThenBy(e => e.Category.Id) ?? Enumerable.Empty(); + foreach (var entry in entries) + { + if (entry.Cover.CoverMeterIds.Count == 0 && entry.Cover.AnalysisOnlyMeterIds.Count > 0) + { + items.Add(new CostAttention(CostAttentionKind.CategoryPricesNothing, entry.Cover.AnalysisOnlyMeterIds[0]) + { + CategoryId = entry.Category.Id, + MeterIds = entry.Cover.AnalysisOnlyMeterIds, + }); + } + } + + return items; + } + + private static Dictionary> Merge(IEnumerable>> maps) + { + var merged = new Dictionary>(); + foreach (var (meter, months) in maps.SelectMany(m => m)) + { + Months(merged, meter).UnionWith(months); + } + + return merged; + } + + /// Whether a meter's billing problem belongs to the scope: its meter, its category's members, its type, or anything. + private bool InScope(ScopePlan scope, int meterId) + { + if (scope.Meter is { } meter) + { + return meterId == meter.MeterId; + } + + if (scope.CategoryEntry is { } entry) + { + return entry.Cover.MemberIds.Contains(meterId); + } + + return scope.Portfolio is not null || scope.Types.Exists(t => t.EnergyTypeId == _catalog.Find(meterId)?.EnergyTypeId); + } + + private CostAnalysis Refused(BucketPlan plan, CostRefusal refusal) => + new( + _request, + plan, + _currency, + [], + CostAmount.Empty, + [], + [], + new ManualCostFigure([], [], [], CostAmount.Empty), + CostAvailability.None, + []) + { + Refusal = refusal, + }; + + private BucketPlan EmptyPlan() + { + var size = _request.Bucket == BucketSize.Auto ? BucketSize.Day : _request.Bucket; + return new BucketPlan(_request.Bucket, size, [], 0, Refused: false, Suggested: null); + } + + // ------------------------------------------------------------------------------------------------ plans + + /// One priced line: whose quantity, in which unit, how, less what. + private sealed record LineSpec( + int MeterId, + int EnergyTypeId, + BillLineKind Kind, + string Unit, + IReadOnlyList Deductions, + int? ForMeterId); + + /// What a physical meter's own scope prices, and how its cost is named. + private sealed record OwnCost(List Lines, MeterCostRule Rule, bool OnBill, MeterNotCostedReason Reason); + + /// The service windows of a type's billed grid meters (inclusive local days) and its use meters (A-17). + private sealed record BasisCheck(List<(DateOnly First, DateOnly Last)> GridService, IReadOnlyList UseMeterIds); + + /// Lines, standing-charge rows and manual costs priced together, and their priced figures. + private sealed class PricingGroup + { + public int? EnergyTypeId { get; init; } + + public BillingBasis Basis { get; init; } + + public List Lines { get; } = []; + + public List Rows { get; } = []; + + public List Manual { get; } = []; + + public CostResult ByBucket { get; set; } = null!; + + public CostResult OverPeriod { get; set; } = null!; + + public List Figures { get; } = []; + + public List RowFigures { get; } = []; + + /// Per line meter: the months whose interval could not be priced because the price changes inside it (A-16). + public Dictionary> PriceChanges { get; } = []; + + /// Per grid meter: the months its type's grid basis missed while use was measured (A-17). + public Dictionary> BasisGaps { get; } = []; + } + + private sealed record CategoryEntry(CostCategory Category, CategoryCoverResult Cover, PricingGroup Group, bool IsView); + + /// Every category's figure and the bill's composition by them. + private sealed class CategoryPlan + { + public Dictionary Entries { get; } = []; + + public IReadOnlyList Disjoint { get; set; } = []; + + public PricingGroup Uncategorized { get; } = new(); + + public IReadOnlyList UncategorizedMeterIds { get; set; } = []; + + public List<(StandingChargeKey Row, PricingGroup Group)> RowSlices { get; } = []; + + public IReadOnlyList Overlaps { get; set; } = []; + + /// The groups the composition prices (every category, Uncategorized, the rows of their own). + public IEnumerable Groups() => + Entries.Values.Select(e => e.Group).Append(Uncategorized).Concat(RowSlices.Select(r => r.Group)); + } + + /// A scope as pricing groups: the groups its figure adds up, and whatever else it prices alongside. + private sealed class ScopePlan + { + public List<(int EnergyTypeId, PricingGroup Group)> Types { get; } = []; + + public PricingGroup? Portfolio { get; set; } + + public PricingGroup? Single { get; init; } + + public MeterCostInfo? Meter { get; init; } + + public CategoryEntry? CategoryEntry { get; init; } + + public CategoryPlan? Categories { get; set; } + + public IReadOnlyList AvailabilityMeters { get; init; } = []; + + public IReadOnlyList ManualInScope { get; init; } = []; + + /// A physical meter's own page: outside its service period it has no data, as its quantity says. + public bool OwnSemantics { get; init; } + + /// The groups whose sum is the scope's figure. + public IEnumerable Groups() + { + if (Single is not null) + { + yield return Single; + yield break; + } + + foreach (var (_, group) in Types) + { + yield return group; + } + + if (Portfolio is not null) + { + yield return Portfolio; + } + } + + /// Everything the request prices: the scope's groups, and for the portfolio the composition's. + public IEnumerable AllGroups() => + Single is null && Categories is not null ? Groups().Concat(Categories.Groups()) : Groups(); + + } +} diff --git a/src/Infrastructure/Costing/CostAnalysisModels.cs b/src/Infrastructure/Costing/CostAnalysisModels.cs new file mode 100644 index 0000000..7eebb32 --- /dev/null +++ b/src/Infrastructure/Costing/CostAnalysisModels.cs @@ -0,0 +1,483 @@ +using System.Globalization; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.Infrastructure.Costing; + +// The shapes the cost reader (CostReader) hands to pages, the API and the export (D-34 – D-43, D-53). Everything is +// data: amounts as CostAmount (value, price coverage and quantity availability kept apart), codes the UI localizes +// and ids it names — never prose. + +/// What a cost request prices. +public enum CostScopeKind +{ + /// The whole bill: every energy type's lines and standing charges, the global standing charge and every manual cost. + Portfolio, + + /// One energy type's bill: its lines, its own standing charge and the manual costs of its meters. + EnergyType, + + /// One meter's cost by its rule (), physical or virtual, and its manual costs. + Meter, + + /// One cost category (D-42): the bill restricted to its members, the standing charges it holds, its manual costs. + Category, +} + +/// The scope of a cost request (D-47 scope=portfolio|type|meter|category). +public sealed class CostScope : IEquatable +{ + private CostScope(CostScopeKind kind, int? id) + { + Kind = kind; + Id = id; + } + + public static CostScope Portfolio { get; } = new(CostScopeKind.Portfolio, null); + + public CostScopeKind Kind { get; } + + /// The energy type, meter or category id; null for the portfolio. + public int? Id { get; } + + public static CostScope ForEnergyType(int energyTypeId) => new(CostScopeKind.EnergyType, energyTypeId); + + public static CostScope ForMeter(int meterId) => new(CostScopeKind.Meter, meterId); + + public static CostScope ForCategory(int categoryId) => new(CostScopeKind.Category, categoryId); + + public bool Equals(CostScope? other) => other is not null && Kind == other.Kind && Id == other.Id; + + public override bool Equals(object? obj) => Equals(obj as CostScope); + + public override int GetHashCode() => HashCode.Combine(Kind, Id); + + public override string ToString() => Kind switch + { + CostScopeKind.Portfolio => "portfolio", + CostScopeKind.EnergyType => string.Create(CultureInfo.InvariantCulture, $"type:{Id}"), + CostScopeKind.Meter => string.Create(CultureInfo.InvariantCulture, $"meter:{Id}"), + _ => string.Create(CultureInfo.InvariantCulture, $"category:{Id}"), + }; +} + +/// +/// One cost request: a scope and a period resolved once by the caller (D-01, D-03), in buckets of a size or a plan the +/// caller already has (a quantity result's, so the cost chart shares its buckets). +/// +/// What to price. +/// The period, resolved in the instance zone; its is the request's "now". +public sealed record CostAnalysisRequest(CostScope Scope, ResolvedPeriod Period) +{ + /// The bucket size; chooses from the coverage of the priced meters (D-05). + public BucketSize Bucket { get; init; } = BucketSize.Auto; + + /// Buckets planned by the caller (e.g. ), used as they are instead of . + public BucketPlan? Plan { get; init; } + + /// The point limit (D-05). + public int MaxPoints { get; init; } = AnalysisLimits.MaxPoints; + + /// For the portfolio: also the category composition of the bill (D-42). + public bool IncludeCategories { get; init; } +} + +/// Why a cost request was refused without pricing anything. +public enum CostRefusal +{ + None, + + /// The bucket size would exceed the point limit; names a coarser one. + TooManyPoints, + + /// The meter or category does not exist. + UnknownScope, +} + +/// How a meter's own cost is formed (D-34, D-35, D-39) — named next to every meter cost. +public enum MeterCostRule +{ + /// + /// A line of its energy type's bill, priced exactly as the bill prices it: the billed grid import (less any + /// separately billed subsection), a subsection at its own price, or an export credited at the feed-in price. + /// + BillLine, + + /// + /// Not on the bill (a subsection, household use behind a grid meter), so its quantity is priced at its unit price + /// by the normal precedence — a view of what it costs, never added to the bill. + /// + UnitPriceView, + + /// An export meter kept off the bill by an override, shown with the credit its export would earn. + FeedInView, + + /// A virtual pure sum: the metered costs of its sources, each at its own price (D-39). + SourceCosts, + + /// A virtual linear formula: its evaluated quantity at its unit price by the normal precedence (D-39). + OwnQuantity, + + /// Not costed; says why. + None, +} + +/// Why a meter has no cost (). +public enum MeterNotCostedReason +{ + None, + + /// Generation is never billed (D-34); its value shows on the solar view. + Generation, + + /// Operating time is not billed (D-34); the fuel it burns is billed on the tank. + Runtime, + + /// A virtual meter whose cost rule is none (a difference, a ratio, or set so, D-39). + NoCostRule, + + /// A virtual meter whose definition cannot be evaluated (invalid, malformed, needs configuration). + NotEvaluable, + + /// + /// A virtual meter with the source-costs rule over a nested calculation that is not a plain sum (a difference, say): + /// its sources' metered costs do not add up to its quantity's, so it has none (D-39, A-15). + /// + SourcesNotPureSum, +} + +/// How a meter-scope cost was formed. +/// The meter. +/// The rule. +/// True when the meter is a line of its energy type's bill (for a virtual meter: through an override, D-23). +/// Why there is no cost, for . +/// For : the physical sources priced, ascending. +public sealed record MeterCostInfo(int MeterId, MeterCostRule Rule, bool OnBill, MeterNotCostedReason NotCosted, IReadOnlyList SourceIds); + +/// +/// One priced line: a meter's quantity per bucket at its price, and in total. +/// +/// The meter whose quantity is priced (for a virtual meter's source costs: the source). +/// Its name (user data, never translated). +/// Its energy type (tariff precedence meter, type, global). +/// How it is priced. +/// The normalized unit its quantity is priced in (D-20). +/// One figure per bucket of . +/// The figure over the whole period, priced month by month (D-36). +/// The priced (net) quantity per bucket: the sum of the known parts, or null when none is known. +/// The priced quantity over the period. +public sealed record CostLineFigure( + int MeterId, + string Name, + int EnergyTypeId, + BillLineKind Kind, + string Unit, + IReadOnlyList Buckets, + CostAmount Total, + IReadOnlyList Quantities, + double? TotalQuantity) +{ + /// The separately billed subsections taken out of the quantity before pricing (D-35). + public IReadOnlyList Deductions { get; init; } = []; + + /// For an own-price line: the months it was billed inside its parent for want of an own price. + public IReadOnlyList MonthsWithoutOwnPrice { get; init; } = []; + + /// For a source line of a virtual meter's source costs (D-39): that virtual meter. + public int? ForMeterId { get; init; } +} + +/// A type- or global-scoped standing-charge row ("Standing charge — <type>" / "— global", D-40). +/// or . +/// The energy type id; null for the global row. +/// One figure per bucket. +/// The figure over the period. +/// The scope's service period the charge accrues over (D-40); null when nothing is in service. +public sealed record StandingChargeFigure( + TariffScope Scope, + int? ScopeId, + IReadOnlyList Buckets, + CostAmount Total, + ServicePeriod? Service); + +/// A standing-charge row's identity: an energy type's, or the global one. +public sealed record StandingChargeKey(TariffScope Scope, int? ScopeId); + +/// The manual costs of a figure (D-41): booked in full on their start day, up to today. +/// Every manual cost booked, with the bucket it falls in. +/// Manual costs inside the period but after today: not booked yet. +/// The booked amounts per bucket. +/// The booked amount over the period. +public sealed record ManualCostFigure( + IReadOnlyList Bookings, + IReadOnlyList AfterTodayIds, + IReadOnlyList Buckets, + CostAmount Total); + +/// One energy type's part of a bill: its lines, its standing charge and the manual costs of its meters. +public sealed record EnergyTypeCostFigure( + int EnergyTypeId, + BillingBasis Basis, + IReadOnlyList Buckets, + CostAmount Total, + IReadOnlyList LineMeterIds); + +/// +/// A cost category's figure (D-42): the priced cover of its members, the standing charges it holds and its manual +/// costs. +/// +/// The category. +/// Its name (user data). +/// Its colour. +/// Its sort key. +/// Which members are priced, credited or only analysed, and whether it lies outside the bill. +/// One figure per bucket. +/// The figure over the period. +public sealed record CategoryCostFigure( + int CategoryId, + string Name, + string? ColorHex, + int Sort, + CategoryCoverResult Cover, + IReadOnlyList Buckets, + CostAmount Total) +{ + /// + /// True when the category is a view on the bill rather than a slice of it: it prices something the bill does not, + /// or shares a line, a manual cost or a standing charge with another category. It stays outside the composition. + /// + public bool IsOverlappingView { get; init; } + + /// The categories it shares something with, ascending. + public IReadOnlyList OverlapsWith { get; init; } = []; + + /// The manual costs it holds: its own, and those of its meters (D-41). + public IReadOnlyList ManualCostIds { get; init; } = []; + + /// The type or global standing charges it holds (D-42: the whole type, or every billed meter, is a member). + public IReadOnlyList StandingCharges { get; init; } = []; +} + +/// What a slice of the bill's composition is. +public enum CompositionSliceKind +{ + /// A disjoint category. + Category, + + /// Bill lines and manual costs no disjoint category holds. + Uncategorized, + + /// A type or global standing charge no disjoint category holds. + StandingCharge, +} + +/// One slice of the bill's composition (D-42). +/// What the slice is. +/// For a category slice, the category. +/// For a standing-charge slice, which row. +/// One figure per bucket. +/// The figure over the period. +public sealed record CompositionSlice( + CompositionSliceKind Kind, + int? CategoryId, + StandingChargeKey? StandingCharge, + IReadOnlyList Buckets, + CostAmount Total) +{ + /// The bill lines in the slice (their meters), ascending. + public IReadOnlyList MeterIds { get; init; } = []; + + /// The manual costs in the slice, ascending. + public IReadOnlyList ManualCostIds { get; init; } = []; +} + +/// Two categories that share part of the bill, so both are views. +public sealed record CostCategoryOverlap( + int CategoryId, + int OtherCategoryId, + IReadOnlyList SharedMeterIds, + IReadOnlyList SharedManualCostIds, + IReadOnlyList SharedStandingCharges); + +/// +/// The bill broken down by category (D-42): the disjoint categories, Uncategorized and the standing-charge rows no +/// category holds add up to the bill; overlapping views are listed apart and never summed. +/// +/// The composition, in category sort order, then Uncategorized, then the standing-charge rows. +/// Every category's own figure, disjoint and views alike, in sort order. +/// Every pair of categories sharing part of the bill. +/// The slices added up, per bucket — the bill's buckets. +/// The slices added up — the bill's total. +public sealed record CategoryComposition( + IReadOnlyList Slices, + IReadOnlyList Categories, + IReadOnlyList Overlaps, + IReadOnlyList Buckets, + CostAmount Total) +{ + /// The tolerance below zero a slice may reach and still count as non-negative (rounding of credits). + public const double DonutTolerance = 0.005; + + /// + /// True when a donut may show the composition: every slice with a known cost over the period is ≥ 0. Otherwise + /// (a credit larger than its charges) the composition is shown as signed bars. Slices without a known cost (not + /// priced, unavailable) have no angle either way. + /// + public bool DonutAllowed => Slices.All(s => s.Total.Cost is not { } cost || cost >= -DonutTolerance); +} + +/// What a cost attention item (D-53) is about; a code the UI localizes. +public enum CostAttentionKind +{ + /// + /// A price the figure needed and did not get (D-38): no tariff at all, a gap in the history, or a tariff in the + /// wrong unit — scope and first month in for the tariff deep link (D-52). A missing + /// feed-in price is an optional credit (). + /// + MissingPrice, + + /// A tariff was applied although its unit could not be checked (D-37). + UnverifiedTariffUnit, + + /// Manual costs inside the period start after today, so they are not booked yet (D-41). + ManualCostAfterToday, + + /// Manual costs in another currency than the instance's were booked as they are (D-43). + ManualCostCurrency, + + /// A virtual meter counted in the bill by an override (D-23) has the cost rule none, so its line is left out. + VirtualNotCosted, + + /// The billing configuration was worked around (D-35): a meter price nothing uses, or a subsection in another unit. + BillingConfiguration, + + /// + /// A meter's reading interval spans several local months (a tank dipped every few months, a quarterly delta), and + /// the price changes inside it: the interval cannot be priced month by month, and one price does not cover it, so + /// its cost is unavailable (A-16). to + /// are the months affected. + /// + PriceChangeInsideInterval, + + /// + /// The type bills its grid import, but in some months no grid_import meter was in service while its use was measured + /// (before the grid meter was installed, after it was retired without a successor): those months' bill is + /// unavailable rather than the grid meter's known zero (A-17). is the grid meter. + /// + BillingBasisGap, + + /// + /// A cost category whose members give it no cost line (D-42, D-39): they are calculated views, generation or operating + /// hours, which a category leaves out of its cost although they may have a cost of their own. Its cost is then only + /// what else it holds (manual costs), not an unexplained empty figure (A-22). + /// is the category, the members that add nothing. + /// + CategoryPricesNothing, +} + +/// One cost attention item: its kind, the meter it is about, and the data behind it. +public sealed record CostAttention(CostAttentionKind Kind, int? MeterId) +{ + /// For : what is missing, where and from when. + public MissingPrice? Price { get; init; } + + /// For : the tariff. + public TariffWarning? Warning { get; init; } + + /// For the manual-cost items: the manual costs, ascending. + public IReadOnlyList ManualCostIds { get; init; } = []; + + /// For : the problem. + public TotalsProblem? Totals { get; init; } + + /// For and : the first month affected (its 1st). + public DateOnly? FirstMonth { get; init; } + + /// The last month affected (its 1st). + public DateOnly? LastMonth { get; init; } + + /// For : the category. + public int? CategoryId { get; init; } + + /// For : the members that add nothing, ascending. + public IReadOnlyList MeterIds { get; init; } = []; +} + +/// +/// What a cost scope has data for (D-19), capped at now: its priced meters' coverage and its manual costs' days, and +/// the latest local month holding either. +/// +/// The coverage of the meters the scope prices. +/// The start days of the scope's manual costs up to today. +/// Both together — what the all preset spans on a cost view. +/// The latest month with data and what it rests on. +public sealed record CostAvailability(AvailableRange? Metered, AvailableRange? Manual, AvailableRange? Range, LatestPeriod? Latest) +{ + public static CostAvailability None { get; } = new(null, null, null, null); +} + +/// +/// The priced result of one cost request: per bucket and over the period, with every line, standing charge and manual +/// cost behind it, the scope's availability and its attention items. +/// +/// +/// The period total is priced over the period's own local months, not added up from the buckets (D-36): changing the +/// bucket size never changes it, and a monthly import that cannot be cut into days still prices its months. When the +/// data resolves the buckets, the buckets add up to it. +/// +/// The request. +/// The buckets. +/// The instance currency every amount is in (D-43). +/// The figure per bucket. +/// The figure over the period. +/// The priced lines. +/// The type and global standing-charge rows (only those with a base price). +/// The manual costs. +/// What the scope has data for, and its latest month (D-19). +/// The cost attention items (D-53). +public sealed record CostAnalysis( + CostAnalysisRequest Request, + BucketPlan Plan, + string Currency, + IReadOnlyList Buckets, + CostAmount Total, + IReadOnlyList Lines, + IReadOnlyList StandingCharges, + ManualCostFigure ManualCosts, + CostAvailability Availability, + IReadOnlyList Attention) +{ + /// Set when the request was refused before anything was priced. + public CostRefusal Refusal { get; init; } + + /// For the portfolio and a type: each energy type's part. + public IReadOnlyList EnergyTypes { get; init; } = []; + + /// For a meter: how its cost was formed. + public MeterCostInfo? Meter { get; init; } + + /// For a category: its figure and how it relates to the others. + public CategoryCostFigure? Category { get; init; } + + /// For the portfolio with : the composition (D-42). + public CategoryComposition? Composition { get; init; } + + /// Tariffs applied with an unchecked unit (D-37), each once with the first month it priced. + public IReadOnlyList Warnings { get; init; } = []; + + /// What the quantity reader reported about the priced meters (being prepared, recorded after now, invalid definitions). + public IReadOnlyList QuantityProblems { get; init; } = []; + + /// The requested period. + public ResolvedPeriod Period => Request.Period; + + /// The whole range lies after now (D-04). + public bool NotYetOccurred => Period.HasNotStarted(); + + /// Every price the figure needed and did not get. + public IReadOnlyList MissingPrices => Total.MissingPrices; +} diff --git a/src/Infrastructure/Costing/CostModels.cs b/src/Infrastructure/Costing/CostModels.cs index 047605e..fe7ad39 100644 --- a/src/Infrastructure/Costing/CostModels.cs +++ b/src/Infrastructure/Costing/CostModels.cs @@ -1,10 +1,74 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; + namespace MeterVault.Infrastructure.Costing; -/// Cost and consumption/generation for one meter in one time bucket. -public sealed record MeterCostBucket(DateOnly Period, double Consumption, double Generation, double Cost); +/// +/// Cost and consumption/generation for one meter in one time bucket — the shape of /api/v1/cost and +/// /api/v1/consumption (D-45). The positional values keep their legacy meaning and type; everything the +/// analysis rework knows beyond them arrives as additional properties. +/// +/// The start of the bucket's calendar unit in the instance zone (the 1st for a month). +/// +/// The bucket's quantity when the meter measures anything but generation (consumption, export, runtime, a virtual +/// net or indicator result); 0 otherwise, and 0 when the quantity is unknown — says which. +/// +/// The bucket's generation for a generation meter (or a virtual generation result); 0 otherwise. +/// The meter's cost by its rule (); 0 when nothing could be priced — says why. +public sealed record MeterCostBucket(DateOnly Period, double Consumption, double Generation, double Cost) +{ + /// Whether the quantity can be trusted (D-14): available, partial, unresolved, invalid, pending, missing. + public BucketStatus Status { get; init; } = BucketStatus.Available; -/// Rolled-up cost for a category in one time bucket (meters + manual costs). -public sealed record CategoryCostBucket(DateOnly Period, double Cost); + /// + /// True when the bucket holds quantity data — for a virtual meter, when any of its sources does — rather than + /// existing only for its cost (a meter's standing charge through a reading gap, a manual cost). A consumption + /// listing shows these rows only. + /// + public bool HasQuantity { get; init; } = true; + + /// Why the quantity is not a plain available number (a missing source, a coarse resolution, …). + public ValueIssue Issue { get; init; } + + /// What the quantity measures (D-20). + public QuantityKind Kind { get; init; } = QuantityKind.Consumption; + + /// The normalized unit of the quantity (D-20). + public string Unit { get; init; } = string.Empty; + + /// How much of could be priced (D-38). + public CostStatus CostStatus { get; init; } = CostStatus.Priced; + + /// + /// The availability of the quantities behind (D-14, A-16). A priced month whose draws cannot be + /// placed in it () or that has no data () has + /// no cost, and its of 0 (D-45) is not one; alone cannot say so. + /// + public BucketStatus CostAvailability { get; init; } = BucketStatus.Available; + + /// The prices the bucket needed and did not get (D-38), with the months that lack them. + public IReadOnlyList MissingPrices { get; init; } = []; + + /// + /// How the meter's cost is formed (D-34, D-39). means the meter is not costed: + /// is 0, is and + /// says why. + /// + public MeterCostRule CostRule { get; init; } = MeterCostRule.None; + + /// Why the meter has no cost (generation, runtime, no cost rule, a calculation that cannot be evaluated); None when costed. + public MeterNotCostedReason NotCosted { get; init; } +} + +/// Rolled-up cost for a category in one time bucket (its priced members, standing charges and manual costs, D-42). +public sealed record CategoryCostBucket(DateOnly Period, double Cost) +{ + /// How much of could be priced (D-38). + public CostStatus CostStatus { get; init; } = CostStatus.Priced; + + /// The prices the bucket needed and did not get. + public IReadOnlyList MissingPrices { get; init; } = []; +} /// Bucket granularity for cost/consumption queries. public enum CostBucket diff --git a/src/Infrastructure/Costing/CostReader.cs b/src/Infrastructure/Costing/CostReader.cs new file mode 100644 index 0000000..825154a --- /dev/null +++ b/src/Infrastructure/Costing/CostReader.cs @@ -0,0 +1,99 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Options; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace MeterVault.Infrastructure.Costing; + +/// +/// The cost engine (D-34 – D-43): the bill of the portfolio, of an energy type, of a meter or of a cost category, for +/// one resolved period, in the buckets of a chart — priced from the quantities the shared analysis reader gives, with +/// the Core calculator's rules, so every page, the API and the export agree on what things cost. +/// +/// +/// +/// What is billed. The totals policy decides (D-22, D-34): per energy type the grid import when there is one, +/// otherwise household use; a linked subsection with its own meter price at that price, out of the meter above it +/// (D-35); export credited at the feed-in price; generation, runtime and virtual views never — a virtual meter only +/// through an override (D-23), by its cost rule (D-39). Standing charges accrue per day over the scope's service period, +/// once per scope (D-40); manual costs are booked once, on their start day (D-41). Categories price the non-overlapping +/// cover of their members, and the disjoint ones with Uncategorized and the standing-charge rows compose the bill (D-42). +/// +/// +/// How it is priced. Every bucket is cut into its local months and each part priced at its month's price +/// (D-36); the period total is priced over the period's own months, so the bucket size never changes it. A missing +/// price is "not priced" when the scope has no tariff at all (an attention item, never a reason to call a total +/// partial), a gap when a priced history has a hole, and "unit mismatch" when the tariff does not fit (D-37, D-38). +/// Amounts are in the configured currency (D-43). +/// +/// +/// What it reads. One catalog load, one tariff load, one manual-cost load (and the categories when asked), then +/// the quantities of every meter any figure prices in one reader pass per part set — plus a coverage query for an +/// automatic bucket size, and one for standing-charge service periods when there is a base price. It never reads the +/// clock: "now" is the period's (D-01). +/// +/// +public sealed class CostReader( + IDbContextFactory contextFactory, AnalysisReader reader, IOptions? options = null) +{ + private readonly IDbContextFactory _contextFactory = contextFactory; + private readonly AnalysisReader _reader = reader; + + /// The instance currency every amount is in (D-43, MeterVault__Currency). + public string Currency { get; } = string.IsNullOrWhiteSpace(options?.Value.Currency) ? "EUR" : options!.Value.Currency.Trim(); + + /// The zone local days and months are cut in (the analysis reader's). + public TimeZoneInfo Zone => _reader.Zone; + + /// Prices a scope over a period. + /// The period was resolved in another zone than the reader's. + public async Task ReadAsync(CostAnalysisRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + CheckZone(request.Period.Zone); + + // The point limit comes first, before any SQL (D-15). + var plan = request.Plan + ?? (request.Bucket != BucketSize.Auto ? BucketPlanner.Plan(request.Period, request.Bucket, maxPoints: request.MaxPoints) : null); + if (plan is { Refused: true }) + { + return Refused(request, plan); + } + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + return await new BillRun(db, _reader, request, Currency).ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// What a cost scope has data for as of (D-19): its priced meters' coverage plus its manual + /// costs, and the latest month holding either — for the all preset on a cost view and "go to latest data". + /// + public async Task GetAvailabilityAsync(CostScope scope, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scope); + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var period = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, now, Zone); + return await new BillRun(db, _reader, new CostAnalysisRequest(scope, period), Currency).AvailabilityAsync(cancellationToken) + .ConfigureAwait(false); + } + + private void CheckZone(TimeZoneInfo zone) + { + if (!string.Equals(zone.Id, Zone.Id, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"The period was resolved in '{zone.Id}', but costs are priced in '{Zone.Id}'; resolve periods in the instance zone.", + nameof(zone)); + } + } + + private CostAnalysis Refused(CostAnalysisRequest request, BucketPlan plan) => + new(request, plan, Currency, [], CostAmount.Empty, [], [], new ManualCostFigure([], [], [], CostAmount.Empty), CostAvailability.None, []) + { + Refusal = CostRefusal.TooManyPoints, + }; +} diff --git a/src/Infrastructure/Costing/CostService.cs b/src/Infrastructure/Costing/CostService.cs index 8ac988a..4d0cb07 100644 --- a/src/Infrastructure/Costing/CostService.cs +++ b/src/Infrastructure/Costing/CostService.cs @@ -1,6 +1,6 @@ -using Dapper; -using MeterVault.Core.Costing; -using MeterVault.Core.Domain; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -9,153 +9,222 @@ using Microsoft.Extensions.Options; namespace MeterVault.Infrastructure.Costing; /// -/// Computes cost by joining bucketed consumption with time-ranged tariffs (SDD §7.5). Consumption -/// is aggregated in SQL (Dapper, local-timezone buckets); the active price for each bucket is -/// resolved in C# via using the month's dominant price (SDD §14.3). -/// Categories roll up their member meters' costs plus meterless manual costs. Uses a DbContext -/// factory (short-lived context per operation) so it is safe from a Blazor circuit. +/// The legacy cost entry points — a meter's or a category's cost per calendar bucket between two instants, which the +/// REST API and the older pages call — answered by the analysis reader and the cost engine (D-34 – D-45), so they +/// report what every new view reports for the same range. /// -public sealed class CostService( - IDbContextFactory contextFactory, IOptions? options = null) +/// +/// +/// What changed underneath (D-45, note §10). Quantities are the reader's: actuals stop at now (a row recorded +/// after now is not counted), a virtual meter is evaluated from its formula, and a bucket without data is absent rather +/// than a zero. Costs are the engine's: a meter is priced by its rule ( — the grid meter on +/// the bill, a subsection as a view at its unit price, generation never), month by month at the price of the 15th, +/// with standing charges per day of service and manual costs on their start day. A price the figure needed and did not +/// get leaves Cost at 0 and says so in and +/// . +/// +/// +/// Bounds. The instants are kept exactly (D-45): a range starting at 00:00 UTC in Berlin starts at 01:00 local, +/// and its last bucket may be an hour long. Buckets are filed under the start of their local calendar unit, as the +/// old time_bucket query filed them. +/// +/// +/// Clock and zone. "Now" comes from the (D-01). Without options the zone is UTC — the +/// normalizer's own default, so data it built without options reads as current; the application always passes the +/// configured instance zone. +/// +/// +public sealed class CostService { - private readonly IDbContextFactory _contextFactory = contextFactory; + /// + /// The most buckets a legacy request may produce. The old queries had no limit; this one only stops a runaway day + /// series (four centuries by day), while any month series over the supported dates fits. + /// + internal const int LegacyMaxPoints = 50_000; + + private readonly TimeProvider _time; + + public CostService( + IDbContextFactory contextFactory, IOptions? options = null, TimeProvider? time = null) + { + ArgumentNullException.ThrowIfNull(contextFactory); + + Reader = new AnalysisReader(contextFactory, options); + Costs = new CostReader(contextFactory, Reader, options); + _time = time ?? TimeProvider.System; + } + + /// The zone local months are cut in. + public TimeZoneInfo Zone => Reader.Zone; + + /// The instance currency every cost is in. + public string Currency => Costs.Currency; + + /// The analysis reader this service reads quantities with (same zone as ). + internal AnalysisReader Reader { get; } + + /// The cost engine this service prices with. + internal CostReader Costs { get; } + + /// The current instant, read once per call (D-01). + internal DateTimeOffset Now() => _time.GetUtcNow(); /// - /// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides - /// intervals at, so a share stamped at a month's last second is read back under that month. + /// A meter's quantity and cost per bucket in [from, to): one row per bucket that has data or a cost (a + /// standing charge, a manual cost — tells them apart), in order. A virtual + /// meter is evaluated from its formula; a bucket where some of its sources have data but the result is missing is + /// reported with that status rather than left out. Empty for an unknown meter. /// - private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone; - private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone); - public async Task> GetMeterCostsAsync( int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket = CostBucket.Month, CancellationToken cancellationToken = default) { - await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - - var meter = await db.Meters.AsNoTracking() - .FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); - if (meter is null) + var period = LegacyPeriods.FromInstants(from, to, Now(), Zone); + var plan = BucketPlanner.Plan(period, SizeOf(bucket), maxPoints: LegacyMaxPoints); + if (plan.Refused || plan.Buckets.Count == 0) { return []; } - var tariffs = await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); - var series = await QueryConsumptionAsync(db, meterId, from, to, bucket, _timeZone, cancellationToken).ConfigureAwait(false); - - var results = new List(); - foreach (var period in series.Keys.OrderBy(k => k)) + var quantities = await Reader.ReadAsync( + new AnalysisRequest(AnalysisScope.ForMeter(meterId), period) { Plan = plan, QuantitiesOnly = true }, + cancellationToken).ConfigureAwait(false); + if (quantities.SeriesFor(meterId) is not { } series) { - var (consumption, generation) = series[period]; - var mid = RepresentativeDate(period, bucket); - - var unitPrice = TariffResolver.ResolveValue(tariffs, TariffComponent.UnitPrice, meterId, meter.EnergyTypeId, mid); - var basePrice = TariffResolver.ResolveValue(tariffs, TariffComponent.BasePrice, meterId, meter.EnergyTypeId, mid); - var feedIn = TariffResolver.ResolveValue(tariffs, TariffComponent.FeedIn, meterId, meter.EnergyTypeId, mid); - - // Base price is a monthly standing charge: prorate it to the bucket length. - var cost = (consumption * unitPrice) + (basePrice * MonthsInBucket(period, bucket)) - (generation * feedIn); - results.Add(new MeterCostBucket(period, consumption, generation, cost)); + return []; } - return results; + var costs = await Costs.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meterId), period) { Plan = plan }, cancellationToken) + .ConfigureAwait(false); + + // A meter without a cost rule (generation, runtime, an indicator, a calculation that cannot be evaluated) has no + // cost at all: its months are "not priced", with the quantity's own availability, never a priced, available 0 + // (A-16) — the numeric cost stays 0 (D-45), and the rule and the reason travel beside it. + var rule = costs.Meter?.Rule ?? MeterCostRule.None; + var notCosted = costs.Meter?.NotCosted ?? MeterNotCostedReason.None; + var hasRule = costs.Meter is not null && rule != MeterCostRule.None; + + var rows = new List(); + for (var i = 0; i < plan.Buckets.Count; i++) + { + var value = series.Values[i]; + var cost = i < costs.Buckets.Count ? costs.Buckets[i] : CostAmount.Empty; + var hasQuantity = value.Status != BucketStatus.Missing || AnySourceHasData(series, i); + if (!hasQuantity && cost.Cost is null && cost.MissingPrices.Count == 0) + { + continue; + } + + var amount = value.Value ?? 0; + var isGeneration = series.Kind == QuantityKind.Generation; + rows.Add(new MeterCostBucket( + LegacyPeriods.KeyOf(plan.Buckets[i]), + isGeneration ? 0 : amount, + isGeneration ? amount : 0, + cost.Cost ?? 0) + { + Status = value.Status, + HasQuantity = hasQuantity, + Issue = value.Issue, + Kind = series.Kind, + Unit = series.Unit, + CostStatus = hasRule ? cost.Status : CostStatus.NotPriced, + MissingPrices = cost.MissingPrices, + CostAvailability = CostAvailabilityOf(hasRule, cost, value), + CostRule = rule, + NotCosted = notCosted, + }); + } + + return rows; } + /// + /// Whether a bucket's cost is known (A-16): a meter without a cost rule has the availability of its quantity; a costed + /// one the availability of the quantities its cost priced — and an invalid or pending quantity is never an available + /// cost, whatever was left to price beside it. + /// + private static BucketStatus CostAvailabilityOf(bool hasRule, CostAmount cost, BucketValue value) + { + if (!hasRule) + { + return value.Status; + } + + return cost.Availability == BucketStatus.Available && value.Status is BucketStatus.Invalid or BucketStatus.Pending + ? value.Status + : cost.Availability; + } + + /// + /// A cost category's figure per calendar month in [from, to) (D-42): the priced cover of its members, the + /// standing charges it holds and its manual costs. One row per month that has a cost or lacks a price it needed; + /// empty for an unknown category. + /// public async Task> GetCategoryCostsAsync( int categoryId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default) { - await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - - var members = await db.CostCategoryMembers.AsNoTracking() - .Where(m => m.CategoryId == categoryId) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - var meterIds = new HashSet(); - foreach (var member in members) + var period = LegacyPeriods.FromInstants(from, to, Now(), Zone); + var plan = BucketPlanner.Plan(period, BucketSize.Month, maxPoints: LegacyMaxPoints); + if (plan.Refused || plan.Buckets.Count == 0) { - if (member.MeterId is { } meterId) - { - meterIds.Add(meterId); - } - - if (member.EnergyTypeId is { } energyTypeId) - { - var byType = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId).Select(m => m.Id) - .ToListAsync(cancellationToken).ConfigureAwait(false); - meterIds.UnionWith(byType); - } + return []; } - var totals = new Dictionary(); - foreach (var meterId in meterIds) + var costs = await Costs.ReadAsync(new CostAnalysisRequest(CostScope.ForCategory(categoryId), period) { Plan = plan }, cancellationToken) + .ConfigureAwait(false); + if (costs.Refusal != CostRefusal.None) { - foreach (var mc in await GetMeterCostsAsync(meterId, from, to, CostBucket.Month, cancellationToken).ConfigureAwait(false)) + return []; + } + + var rows = new List(); + for (var i = 0; i < plan.Buckets.Count; i++) + { + var cost = costs.Buckets[i]; + if (cost.Cost is null && cost.MissingPrices.Count == 0) { - totals[mc.Period] = totals.GetValueOrDefault(mc.Period) + mc.Cost; + continue; } + + rows.Add(new CategoryCostBucket(LegacyPeriods.KeyOf(plan.Buckets[i]), cost.Cost ?? 0) + { + CostStatus = cost.Status, + MissingPrices = cost.MissingPrices, + }); } - // Manual costs are dated in local months; the instants passed in are local midnights. - var fromDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(from, _zone).Date); - var toDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(to, _zone).Date); - var manualCosts = await db.ManualCosts.AsNoTracking() - .Where(c => c.CategoryId == categoryId && c.PeriodStart >= fromDate && c.PeriodStart < toDate) - .ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var cost in manualCosts) - { - var period = new DateOnly(cost.PeriodStart.Year, cost.PeriodStart.Month, 1); - totals[period] = totals.GetValueOrDefault(period) + cost.Amount; - } - - return [.. totals.OrderBy(kv => kv.Key).Select(kv => new CategoryCostBucket(kv.Key, kv.Value))]; + return rows; } - private static async Task> QueryConsumptionAsync( - MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket, string tz, - CancellationToken cancellationToken) + /// + /// An energy type's bill over [from, to) (D-34, D-40, D-41): its billed meters (the grid import when there is + /// one, otherwise household use — never every meter of the type), its own standing charge and the manual costs of its + /// meters. for a range with nothing to price. + /// + public async Task GetEnergyTypeCostAsync( + int energyTypeId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default) { - var interval = bucket switch + var period = LegacyPeriods.FromInstants(from, to, Now(), Zone); + var plan = LegacyPeriods.WholePeriodPlan(period); + if (plan.Buckets.Count == 0) { - CostBucket.Day => "1 day", - CostBucket.Year => "1 year", - _ => "1 month", - }; - - var sql = - $"SELECT (time_bucket(INTERVAL '{interval}', \"time\", @tz) AT TIME ZONE @tz)::date AS period, " + - "kind, sum(amount) AS amount " + - "FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " + - "GROUP BY period, kind"; - - var connection = db.Database.GetDbConnection(); - var command = new CommandDefinition(sql, new { meterId, from, to, tz }, cancellationToken: cancellationToken); - var rows = await connection.QueryAsync(command).ConfigureAwait(false); - - var result = new Dictionary(); - foreach (var row in rows) - { - var current = result.GetValueOrDefault(row.Period); - result[row.Period] = row.Kind == (short)ConsumptionKind.Generation - ? (current.Item1, current.Item2 + row.Amount) - : (current.Item1 + row.Amount, current.Item2); + return CostAmount.Empty; } - return result; + var costs = await Costs.ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(energyTypeId), period) { Plan = plan }, cancellationToken) + .ConfigureAwait(false); + return costs.Total; } - private static double MonthsInBucket(DateOnly period, CostBucket bucket) => bucket switch + internal static BucketSize SizeOf(CostBucket bucket) => bucket switch { - CostBucket.Day => 1.0 / DateTime.DaysInMonth(period.Year, period.Month), - CostBucket.Year => 12.0, - _ => 1.0, + CostBucket.Day => BucketSize.Day, + CostBucket.Year => BucketSize.Year, + _ => BucketSize.Month, }; - private static DateOnly RepresentativeDate(DateOnly period, CostBucket bucket) => bucket switch - { - CostBucket.Day => period, - CostBucket.Year => new DateOnly(period.Year, 7, 1), - _ => new DateOnly(period.Year, period.Month, 15), - }; - - private sealed record ConsumptionRow(DateOnly Period, short Kind, double Amount); + /// For a virtual meter: whether any source has a value in the bucket, although the result is missing (strict, D-27). + private static bool AnySourceHasData(AnalysisSeries series, int bucket) => + series.Contributions.Any(c => bucket < c.Values.Count && c.Values[bucket].Value is not null); } diff --git a/src/Infrastructure/Dashboard/ConsumableModels.cs b/src/Infrastructure/Dashboard/ConsumableModels.cs index d61084d..086486f 100644 --- a/src/Infrastructure/Dashboard/ConsumableModels.cs +++ b/src/Infrastructure/Dashboard/ConsumableModels.cs @@ -1,38 +1,197 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; namespace MeterVault.Infrastructure.Dashboard; +// The Tanks & consumables view's read model (SDD §8.5, brief §7.5, D-54). Two things are kept apart on purpose: the +// tank's state now — the last dipstick as it was measured, the contents estimated from it and the deliveries since, a +// forecast that is always a projection — and what happened in the selected period: usage per bucket, deliveries in the +// period, the contents at the period's end, burner runtime and cost, each with its status. + +/// What the Tanks & consumables view reads: a period resolved once by the page (D-01), a bucket size and a comparison. +/// The period, resolved in the instance zone; its is the request's "now". +public sealed record ConsumableRequest(ResolvedPeriod Period) +{ + /// The bucket size; automatic by default (D-05). + public BucketSize Bucket { get; init; } = BucketSize.Auto; + + /// What to compare with (D-06); none by default. + public ComparisonRequest Comparison { get; init; } = ComparisonRequest.None; +} + /// One recorded delivery into a consumable store. public sealed record DeliveryRow(DateTimeOffset Time, double Amount, string? Unit); /// A consumable-balance meter with no tank yet, so it has no level, fill or forecast to show. public sealed record UnconfiguredConsumable(int MeterId, string Name); -/// One month of consumable draw. -public sealed record ConsumableMonth(DateOnly Period, double Consumption); +/// A dipstick (a tank level event): when, the volume it means, and what was read (cm before calibration). +/// The instant it was taken. +/// The contents it means, in the tank's unit (a cm reading through the calibration). +/// The value as recorded. +/// The unit as recorded (cm, L, …). +public sealed record TankDipstick(DateTimeOffset Time, double Volume, double Reading, string? ReadingUnit) +{ + /// True when the reading was a level in cm that the calibration turned into a volume. + public bool IsCalibrated { get; init; } +} /// -/// The oil / consumable panel read model (SDD §8.5) for one -/// meter: current tank level (physical + volume), fill vs capacity, deliveries, associated burner -/// runtime, effective L/h (fixed or empirical), forecast-to-empty, tariff cost and a monthly series. +/// The contents of a tank at an instant as the records know them: the last dipstick up to then plus the deliveries +/// recorded after it. Use since that dipstick is not known, so it is not deducted — this is "the last dipstick plus +/// deliveries", never a measurement at . /// -public sealed record ConsumableSummary( - int MeterId, - string Name, - string Unit, - double Capacity, - double? CurrentLevel, - double? PhysicalLevel, - string? PhysicalUnit, - DateTimeOffset? LevelAsOf, - double FillFraction, - double ConsumptionInRange, - double? BurnerHours, - double? EffectiveRate, - double? FixedRate, - TankRateMode RateMode, - double? AveragePerDay, - DateOnly? ForecastEmpty, - double CostInRange, - IReadOnlyList Deliveries, - IReadOnlyList Months); +/// The instant it describes. +/// The dipstick's volume plus the deliveries since. +/// The dipstick it rests on. +/// What was delivered after the dipstick, up to . +/// How many deliveries that was. +public sealed record TankContents(DateTimeOffset AsOf, double Volume, TankDipstick Dipstick, double DeliveredSince, int DeliveriesSince); + +/// Whether a forecast to empty can be made (D-54, D-09). +public enum TankForecastState +{ + /// A straight-line projection from the dipsticks of up to a year before the last one. + Projected, + + /// No dipstick was ever recorded. + NoDipstick, + + /// The last dipstick is older than days: the projection would be a guess. + DipstickTooOld, + + /// The dipsticks span less than days, too short to draw a line through. + NotEnoughHistory, + + /// Nothing was drawn between the dipsticks, so the tank would never run empty on that line. + NoUse, +} + +/// +/// The forecast to empty (SDD §7.3, D-54): always a projection — straight-line from the draw between the dipsticks of the +/// last year — and suppressed when the last dipstick is too old or the line too short. +/// +/// Whether there is a projection, or why not. +public sealed record TankForecast(TankForecastState State) +{ + /// The forecast is suppressed when the last dipstick is older than this (D-54). + public const int MaxDipstickAgeDays = 60; + + /// The dipsticks the line is drawn through must span at least this many days (D-09). + public const int MinBasisDays = 30; + + /// How far before the last dipstick the line reaches back. + public const int WindowDays = 365; + + /// The local day the projection reaches zero. + public DateOnly? EmptyOn { get; init; } + + /// The average draw per day of the line, in the tank's unit. + public double? PerDay { get; init; } + + /// The days between the dipsticks the line is drawn from. + public int BasisDays { get; init; } + + /// How old the last dipstick is, in whole days; null without one. + public int? DipstickAgeDays { get; init; } +} + +/// Where a tank's burn rate comes from. +public enum TankRateSource +{ + /// The nozzle rating set on the tank (). + Fixed, + + /// Usage in the period ÷ burner hours in the period. + Empirical, +} + +/// A tank's burn rate: per hour of burner runtime, in the tank's unit per hour. +/// Where it comes from. +/// The rate, with its status (an empirical rate over incomplete figures is partial). +/// "L/h" for a tank in litres. +public sealed record TankRate(TankRateSource Source, BucketValue Value, string Unit); + +/// One tank of the view. +/// The consumable-balance meter. +/// Its name (user data). +/// Its energy type. +/// The tank's unit (D-20). +/// The tank's capacity in . +public sealed record TankAnalysis(int MeterId, string Name, int EnergyTypeId, string Unit, double Capacity) +{ + /// The last dipstick up to now, as it was measured; null without one. + public TankDipstick? LastDipstick { get; init; } + + /// The contents now: the last dipstick plus the deliveries since (use since is not deducted). + public TankContents? EstimatedNow { get; init; } + + /// as a share of the capacity (may exceed 1 with a bad calibration); null when either is unknown. + public double? FillFraction { get; init; } + + /// The tank's low threshold, in its unit, if set. + public double? LowThreshold { get; init; } + + /// The tank's reorder threshold, in its unit, if set. + public double? ReorderThreshold { get; init; } + + /// The forecast to empty, or why there is none. + public TankForecast Forecast { get; init; } = new(TankForecastState.NoDipstick); + + /// True when the period ends before now: its end has a balance of its own, not today's. + public bool PeriodEndsBeforeNow { get; init; } + + /// For a period ending before now, the contents at its end (the last dipstick before then plus deliveries); null otherwise or without a dipstick by then. + public TankContents? AtPeriodEnd { get; init; } + + /// The deliveries in the period, newest first. + public IReadOnlyList Deliveries { get; init; } = []; + + /// What was delivered in the period. + public double DeliveredInPeriod => Deliveries.Sum(d => d.Amount); + + /// The tank's usage per bucket and in total (the reader's own series of the meter, D-15). + public AnalysisSeries? Usage { get; init; } + + /// The runtime meters of the tank's energy type (the burners it feeds), each as its own series. + public IReadOnlyList Runtime { get; init; } = []; + + /// The runtime meters' total in the period, in ; null without runtime meters. + public BucketValue? RuntimeTotal { get; init; } + + /// The unit of (D-20: hours, or the tank's unit for a fixed-rate conversion). + public string? RuntimeUnit { get; init; } + + /// How the tank's rate is configured. + public TankRateMode RateMode { get; init; } + + /// The burn rate, when it can be given. + public TankRate? Rate { get; init; } + + /// The tank's own cost in the period, by the cost engine (D-34 – D-38); null when the request was refused. + public CostAnalysis? Cost { get; init; } +} + +/// The Tanks & consumables view. +/// The requested period. +/// The instance currency (D-43). +/// The consumable meters with a tank, by name. +/// Consumable meters without a tank, by name. +public sealed record ConsumableAnalysis( + ResolvedPeriod Period, string Currency, IReadOnlyList Tanks, IReadOnlyList Unconfigured) +{ + /// The read of every tank and runtime meter (plan, comparison, availability, problems); null without tanks. + public AnalysisResult? Quantities { get; init; } + + /// The buckets every tank is read in; null without tanks. + public BucketPlan? Plan => Quantities?.Plan; + + /// True when the request was refused before anything was read (too many points). + public bool IsRefused => Quantities is { Refusal: not AnalysisRefusal.None }; + + /// What the tanks have data for (D-19). + public AvailableRange? Availability => Quantities?.Availability.Quantity; +} diff --git a/src/Infrastructure/Dashboard/ConsumableService.cs b/src/Infrastructure/Dashboard/ConsumableService.cs index e0d8aac..d53e1b9 100644 --- a/src/Infrastructure/Dashboard/ConsumableService.cs +++ b/src/Infrastructure/Dashboard/ConsumableService.cs @@ -1,202 +1,241 @@ -using Dapper; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Normalization; -using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; namespace MeterVault.Infrastructure.Dashboard; /// -/// Read model for the oil / consumable panel (SDD §8.5). Works for any -/// meter backed by a — heating oil is -/// only the reference case. Current level is the latest dipstick reading (cm calibrated to volume) -/// plus deliveries recorded since; the effective burn rate pairs the consumable's litres with the -/// runtime hours of same-energy-type meters. DbContext -/// factory keeps it Blazor-circuit safe. +/// Read model for the Tanks & consumables view (SDD §8.5, brief §7.5, D-54). Works for any +/// meter backed by a — heating oil is only the reference case. /// -public sealed class ConsumableService( - IDbContextFactory contextFactory, CostService costService, IOptions? options = null) +/// +/// +/// Now. The last dipstick up to now as it was measured (cm through the calibration), and apart from it the contents +/// it implies with the deliveries recorded since — the use since is unknown and not deducted. The forecast to empty is a +/// straight-line projection from the dipsticks (), suppressed when the last dipstick is +/// older than 60 days. None of this follows the period. +/// +/// +/// The period. Usage per bucket is each tank's own series from the shared analysis reader (D-15), in the request's +/// buckets and comparison; burner runtime the series of the runtime meters of the tank's energy type (the burners it +/// feeds); deliveries are those inside the period; and a period that ends before now gets the contents at its end — never +/// today's. The cost is the tank's own cost from the cost engine: without a tariff it is "not priced", never 0 (D-38, +/// A-16). One read serves every tank, so they share one plan. It never reads the clock: "now" is the period's (D-01). +/// +/// +public sealed class ConsumableService(IDbContextFactory contextFactory, AnalysisReader reader, CostReader costs) { private readonly IDbContextFactory _contextFactory = contextFactory; - private readonly CostService _costService = costService; + private readonly AnalysisReader _reader = reader; + private readonly CostReader _costs = costs; - /// - /// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides - /// intervals at, so a share stamped at a month's last second is read back under that month. - /// - private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone; - private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone); + /// The zone periods must be resolved in. + public TimeZoneInfo Zone => _reader.Zone; - public async Task> GetConsumablesAsync( - DateOnly from, DateOnly to, CancellationToken cancellationToken = default) + /// Reads every consumable meter for a period. + /// The period was resolved in another zone than the readers'. + public async Task GetAsync(ConsumableRequest request, CancellationToken cancellationToken = default) { - await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(request); - var meters = await db.Meters.AsNoTracking() - .Where(m => m.Mode == MeterMode.ConsumableBalance) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - var summaries = new List(); - foreach (var meter in meters) + var period = request.Period; + var now = period.Now; + List meters; + List tanks; + List runtimeMeters; + List events; + await using (var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false)) { - var tank = await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meter.Id, cancellationToken).ConfigureAwait(false); - if (tank is null) + meters = await db.Meters.AsNoTracking() + .Where(m => m.Mode == MeterMode.ConsumableBalance) + .ToListAsync(cancellationToken).ConfigureAwait(false); + var meterIds = meters.Select(m => m.Id).ToList(); + tanks = await db.Tanks.AsNoTracking().Where(t => meterIds.Contains(t.MeterId)).ToListAsync(cancellationToken).ConfigureAwait(false); + var types = meters.Select(m => m.EnergyTypeId).Distinct().ToList(); + runtimeMeters = await db.Meters.AsNoTracking() + .Where(m => m.Mode == MeterMode.RuntimeCounter && types.Contains(m.EnergyTypeId)) + .OrderBy(m => m.Id) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + // Level and delivery events up to now only (D-04): a dipstick dated in the future is not the current level. + events = await db.MeterEvents.AsNoTracking() + .Where(e => meterIds.Contains(e.MeterId) && e.Time <= now + && (e.EventType == MeterEventType.TankLevel || e.EventType == MeterEventType.Delivery)) + .OrderBy(e => e.Time) + .ThenBy(e => e.Id) + .ToListAsync(cancellationToken).ConfigureAwait(false); + } + + var tankOf = tanks.GroupBy(t => t.MeterId).ToDictionary(g => g.Key, g => g.OrderBy(t => t.Id).First()); + var ordered = meters.OrderBy(m => m.Name, StringComparer.CurrentCulture).ThenBy(m => m.Id).ToList(); + List unconfigured = [.. ordered.Where(m => !tankOf.ContainsKey(m.Id)).Select(m => new UnconfiguredConsumable(m.Id, m.Name))]; + var configured = ordered.Where(m => tankOf.ContainsKey(m.Id)).ToList(); + if (configured.Count == 0) + { + return new ConsumableAnalysis(period, _costs.Currency, [], unconfigured); + } + + var runtimeByType = runtimeMeters.GroupBy(m => m.EnergyTypeId).ToDictionary(g => g.Key, g => g.Select(m => m.Id).ToList()); + List ids = [.. configured.Select(m => m.Id).Concat(runtimeMeters.Where(r => configured.Exists(m => m.EnergyTypeId == r.EnergyTypeId)).Select(r => r.Id))]; + var quantities = await _reader.ReadAsync( + new AnalysisRequest(AnalysisScope.ForMeters(ids), period) { - continue; + Bucket = request.Bucket, + Comparison = request.Comparison, + MaxSeries = int.MaxValue, + }, + cancellationToken).ConfigureAwait(false); + + var eventsOf = events.GroupBy(e => e.MeterId).ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); + var result = new List(); + foreach (var meter in configured) + { + var tank = tankOf[meter.Id]; + var tankEvents = eventsOf.GetValueOrDefault(meter.Id) ?? []; + var runtime = runtimeByType.GetValueOrDefault(meter.EnergyTypeId) ?? []; + CostAnalysis? cost = null; + if (quantities.Refusal == AnalysisRefusal.None) + { + cost = await _costs.ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(meter.Id), period) { Plan = quantities.Plan }, cancellationToken) + .ConfigureAwait(false); } - summaries.Add(await BuildAsync(db, meter, tank, from, to, cancellationToken).ConfigureAwait(false)); + result.Add(Build(meter, tank, tankEvents, period, quantities, runtime, cost)); } - return summaries; + return new ConsumableAnalysis(period, _costs.Currency, result, unconfigured) { Quantities = quantities }; } /// - /// Consumable-balance meters that have no tank row. has to skip - /// them — capacity and calibration come from the tank — so without this they would simply be - /// missing from the panel, with nothing saying why. + /// What the tanks have data for as of (D-19), for the all preset; null when there is + /// none (or no tank). /// - public async Task> GetUnconfiguredAsync(CancellationToken cancellationToken = default) + public async Task GetAvailabilityAsync(DateTimeOffset now, CancellationToken cancellationToken = default) { - await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - - return await db.Meters.AsNoTracking() - .Where(m => m.Mode == MeterMode.ConsumableBalance && !db.Tanks.Any(t => t.MeterId == m.Id)) - .OrderBy(m => m.Name) - .Select(m => new UnconfiguredConsumable(m.Id, m.Name)) - .ToListAsync(cancellationToken).ConfigureAwait(false); - } - - private async Task BuildAsync( - MeterVaultDbContext db, Meter meter, Tank tank, DateOnly from, DateOnly to, CancellationToken cancellationToken) - { - var calibration = MeterConfigFactory.FromMeter(meter, tank).Tank?.Calibration; - var fromUtc = InstanceTimeZone.StartOf(from, _zone); - var toUtc = InstanceTimeZone.StartOf(to, _zone); - - var events = await db.MeterEvents.AsNoTracking() - .Where(e => e.MeterId == meter.Id && (e.EventType == MeterEventType.TankLevel || e.EventType == MeterEventType.Delivery)) - .OrderBy(e => e.Time) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - var lastLevel = events.LastOrDefault(e => e.EventType == MeterEventType.TankLevel); - double? currentLevel = null; - double? physicalLevel = null; - string? physicalUnit = null; - DateTimeOffset? levelAsOf = null; - if (lastLevel is not null) + List ids; + await using (var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false)) { - physicalLevel = lastLevel.Amount; - physicalUnit = lastLevel.Unit; - levelAsOf = lastLevel.Time; - var volume = ToVolume(lastLevel, calibration); - // Deliveries recorded after the last dipstick raise the actual contents. - var since = events.Where(e => e.EventType == MeterEventType.Delivery && e.Time > lastLevel.Time).Sum(e => e.Amount ?? 0); - currentLevel = volume + since; + ids = await db.Meters.AsNoTracking() + .Where(m => m.Mode == MeterMode.ConsumableBalance && db.Tanks.Any(t => t.MeterId == m.Id)) + .OrderBy(m => m.Id) + .Select(m => m.Id) + .ToListAsync(cancellationToken).ConfigureAwait(false); } - var fillFraction = tank.Capacity > 0 && currentLevel is { } level - ? Math.Clamp(level / tank.Capacity, 0, 1) - : 0; - - var deliveries = events - .Where(e => e.EventType == MeterEventType.Delivery) - .OrderByDescending(e => e.Time) - .Select(e => new DeliveryRow(e.Time, e.Amount ?? 0, e.Unit)) - .ToList(); - - var consumptionInRange = await SumConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); - - // Burner runtime: same-energy-type runtime meters feed this consumable's L/h analytic. - var runtimeMeterIds = await db.Meters.AsNoTracking() - .Where(m => m.EnergyTypeId == meter.EnergyTypeId && m.Mode == MeterMode.RuntimeCounter) - .Select(m => m.Id) - .ToListAsync(cancellationToken).ConfigureAwait(false); - double? burnerHours = null; - foreach (var id in runtimeMeterIds) + if (ids.Count == 0) { - burnerHours = (burnerHours ?? 0) + await SumConsumptionAsync(db, id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); + return null; } - double? effectiveRate = burnerHours is > 0 ? consumptionInRange / burnerHours : null; - double? fixedRate = tank.RateMode == TankRateMode.Fixed ? tank.FixedRate : null; - - var (averagePerDay, forecastEmpty) = await ForecastAsync(db, meter.Id, currentLevel, levelAsOf, cancellationToken).ConfigureAwait(false); - - var costInRange = (await _costService.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month, cancellationToken).ConfigureAwait(false)) - .Sum(c => c.Cost); - - var months = await MonthlyConsumptionAsync(db, meter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false); - - return new ConsumableSummary( - meter.Id, meter.Name, tank.Unit, tank.Capacity, currentLevel, physicalLevel, physicalUnit, levelAsOf, - fillFraction, consumptionInRange, burnerHours, effectiveRate, fixedRate, tank.RateMode, - averagePerDay, forecastEmpty, costInRange, deliveries, months); + var availability = await _reader.GetAvailabilityAsync(AnalysisScope.ForMeters(ids), now, cancellationToken).ConfigureAwait(false); + return availability.Quantity; } - /// Recent burn rate and a forecast-to-empty anchored at the last level reading, using - /// the trailing 365 days of consumption (delivery-only early history would otherwise skew it). - private static async Task<(double? AveragePerDay, DateOnly? ForecastEmpty)> ForecastAsync( - MeterVaultDbContext db, int meterId, double? currentLevel, DateTimeOffset? levelAsOf, CancellationToken cancellationToken) + private TankAnalysis Build( + Meter meter, + Tank tank, + IReadOnlyList events, + ResolvedPeriod period, + AnalysisResult quantities, + IReadOnlyList runtimeIds, + CostAnalysis? cost) { - var latest = await db.Consumption.AsNoTracking() - .Where(c => c.MeterId == meterId) - .OrderByDescending(c => c.Time) - .Select(c => (DateTimeOffset?)c.Time) - .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); - if (latest is null || currentLevel is not { } level || level <= 0) + var calibration = MeterConfigFactory.ParseCalibration(tank.Calibration); + var now = period.Now; + var estimated = TankLevels.ContentsAt(events, now, inclusive: true, calibration); + + // A period that ends before now describes its end, not today: the contents then, from the dipsticks up to then. + var endsBeforeNow = !period.IsToDate && !period.HasNotStarted() && period.To <= now; + var atEnd = endsBeforeNow ? TankLevels.ContentsAt(events, period.To, inclusive: false, calibration) : null; + + List deliveries = + [ + .. events.Where(e => e.EventType == MeterEventType.Delivery && e.Time >= period.From && e.Time < period.To) + .OrderByDescending(e => e.Time) + .ThenByDescending(e => e.Id) + .Select(e => new DeliveryRow(e.Time, e.Amount ?? 0, e.Unit)), + ]; + + var usage = quantities.SeriesFor(meter.Id); + List runtime = [.. runtimeIds.Select(quantities.SeriesFor).OfType()]; + var runtimeUnit = runtime.FirstOrDefault(s => Units.AreSame(s.Unit, "h"))?.Unit ?? runtime.FirstOrDefault()?.Unit; + BucketValue? runtimeTotal = runtime.Count == 0 + ? null + : MeasureValues.Sum([.. runtime.Where(s => Units.AreSame(s.Unit, runtimeUnit)).Select(s => (s.MeterId!.Value, s.Total))]); + + return new TankAnalysis(meter.Id, meter.Name, meter.EnergyTypeId, tank.Unit, tank.Capacity) { - return (null, null); + LastDipstick = estimated?.Dipstick, + EstimatedNow = estimated, + FillFraction = estimated is not null && tank.Capacity > 0 ? Math.Max(0, estimated.Volume) / tank.Capacity : null, + LowThreshold = tank.LowThreshold, + ReorderThreshold = tank.ReorderThreshold, + Forecast = TankLevels.Forecast(events, now, period.Zone, calibration), + PeriodEndsBeforeNow = endsBeforeNow, + AtPeriodEnd = atEnd, + Deliveries = deliveries, + Usage = usage, + Runtime = runtime, + RuntimeTotal = runtimeTotal, + RuntimeUnit = runtimeUnit, + RateMode = tank.RateMode, + Rate = RateOf(tank, usage, runtimeTotal, runtimeUnit), + Cost = cost, + }; + } + + /// + /// The burn rate per burner hour: the nozzle rating when the tank is set to a fixed rate, otherwise usage ÷ burner hours + /// over the period — only when both are known, in hours, partial when either is (a ratio of two figures over different + /// stretches of time would be no rate at all). + /// + internal static TankRate? RateOf(Tank tank, AnalysisSeries? usage, BucketValue? runtime, string? runtimeUnit) + { + var unit = tank.Unit + "/h"; + if (tank.RateMode == TankRateMode.Fixed && tank.FixedRate is { } fixedRate) + { + return new TankRate(TankRateSource.Fixed, BucketValue.Available(fixedRate, Provenance.Manual), unit); } - var windowStart = latest.Value.AddDays(-365); - var recent = await db.Consumption.AsNoTracking() - .Where(c => c.MeterId == meterId && c.Time > windowStart && c.Time <= latest.Value) - .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0; - if (recent <= 0) + if (usage is null || runtime is null || !Units.AreSame(runtimeUnit, "h")) { - return (null, null); + return null; } - var averagePerDay = recent / 365.0; - var anchor = levelAsOf ?? latest.Value; - var daysToEmpty = level / averagePerDay; - // Guard against absurd horizons (near-zero burn) that overflow DateTime. - var forecast = daysToEmpty < 365 * 100 - ? DateOnly.FromDateTime(anchor.UtcDateTime.AddDays(daysToEmpty)) - : (DateOnly?)null; - return (averagePerDay, forecast); + var used = usage.Total; + foreach (var spoiled in (ReadOnlySpan)[used, runtime]) + { + if (spoiled.Status is BucketStatus.Pending or BucketStatus.Invalid) + { + return new TankRate(TankRateSource.Empirical, new BucketValue(null, spoiled.Status, Provenance.None, spoiled.Issue), unit); + } + } + + if (used.Value is not { } litres || runtime.Value is not { } hours) + { + var missing = used.Value is null ? used : runtime; + return new TankRate(TankRateSource.Empirical, new BucketValue(null, missing.Status == BucketStatus.Unresolved ? BucketStatus.Unresolved : BucketStatus.Missing, Provenance.None, ValueIssue.MissingSource), unit); + } + + if (hours <= Change.Tolerance) + { + return new TankRate(TankRateSource.Empirical, new BucketValue(null, BucketStatus.Invalid, Provenance.None, ValueIssue.NonFinite), unit); + } + + var provenance = ProvenanceRules.Derive([used.Provenance, runtime.Provenance]); + var complete = used.Status == BucketStatus.Available && runtime.Status == BucketStatus.Available; + return new TankRate( + TankRateSource.Empirical, + complete + ? new BucketValue(litres / hours, BucketStatus.Available, provenance) + : new BucketValue(litres / hours, BucketStatus.Partial, provenance, ValueIssue.PartialCoverage), + unit); } - - private static async Task SumConsumptionAsync( - MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken) => - await db.Consumption.AsNoTracking() - .Where(c => c.MeterId == meterId && c.Time >= from && c.Time < to) - .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0; - - private static async Task> MonthlyConsumptionAsync( - MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, string tz, CancellationToken cancellationToken) - { - const string sql = - "SELECT (time_bucket(INTERVAL '1 month', \"time\", @tz) AT TIME ZONE @tz)::date AS period, " + - "sum(amount) AS amount " + - "FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " + - "GROUP BY period ORDER BY period"; - - var connection = db.Database.GetDbConnection(); - var command = new CommandDefinition(sql, new { meterId, from, to, tz }, cancellationToken: cancellationToken); - var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false); - return rows.Select(r => new ConsumableMonth(r.Period, r.Amount)).ToList(); - } - - private static double ToVolume(MeterEvent level, MeterVault.Core.Normalization.CalibrationCurve? calibration) - { - var value = level.Amount ?? 0; - var isCentimetres = string.Equals(level.Unit, "cm", StringComparison.OrdinalIgnoreCase); - return isCentimetres && calibration is not null ? calibration.ToVolume(value) : value; - } - } diff --git a/src/Infrastructure/Dashboard/DashboardModels.cs b/src/Infrastructure/Dashboard/DashboardModels.cs index a711a5a..f10d890 100644 --- a/src/Infrastructure/Dashboard/DashboardModels.cs +++ b/src/Infrastructure/Dashboard/DashboardModels.cs @@ -1,21 +1,73 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; + namespace MeterVault.Infrastructure.Dashboard; /// KPI card figures for a period plus the delta versus the previous comparable period. +/// +/// The percentage follows the one rule every view uses (D-08, ): it needs a positive baseline. +/// Against a zero, negative or missing baseline it is not applicable — then stays 0 (the +/// field is numeric in /api/v1/dashboard/summary) and says so. Before the +/// rework a negative baseline was divided by its absolute value. +/// public sealed record CostKpi(double Current, double Previous) { + /// The tolerance a cost difference or baseline may have and still count as zero: half a cent. + public const double Tolerance = 0.005; + public double Delta => Current - Previous; - public double DeltaPercent => Previous == 0 ? 0 : (Current - Previous) / Math.Abs(Previous) * 100.0; + /// The change in percent of ; 0 when not applicable (see ). + public double DeltaPercent => ChangeFigure.Percent ?? 0; + + /// True when means something: the baseline is a positive amount (D-08). + public bool DeltaPercentApplicable => ChangeFigure.PercentApplicable; /// +1 up, -1 down, 0 flat. public int Direction => Math.Sign(Math.Round(Delta, 2)); + + private Change ChangeFigure => Change.Between(Current, Previous, Tolerance); } +/// The latest local month with cost data, and what it rests on (D-19): meters, manual costs or both. +public sealed record LatestMonthWithData(DateOnly Period, LatestPeriodBasis Basis); + /// The overview KPIs: month and year cost, each with its previous-period comparison. -public sealed record DashboardSummary(DateOnly AsOf, CostKpi Month, CostKpi Year, double LatestMonthCost); +/// The local date the figures are as of. +/// The calendar month of up to now, against the whole previous month. +/// The calendar year of up to now, against the whole previous year. +/// The bill of (up to now when it is the current month); 0 without data. +/// +/// The month and year windows are the legacy calendar ones (D-45): a to-date period against a complete one, not the +/// matched comparison the overview is moving to. The figures are the bill's (D-34): the billed meters, standing charges +/// per day of service and every manual cost once. +/// +public sealed record DashboardSummary(DateOnly AsOf, CostKpi Month, CostKpi Year, double LatestMonthCost) +{ + /// The month is for, and its basis; null when nothing has data. + public LatestMonthWithData? LatestMonth { get; init; } +} /// One slice of the cost breakdown / "what costs most" view. -public sealed record CategorySlice(string Name, string? ColorHex, double Cost); +/// +/// For a category, its name; for an energy type's standing charge, the type's display name (both user data); empty for +/// Uncategorized and the global standing charge, which the page names in the reader's language. +/// +/// The category's colour, if any. +/// The slice's cost over the period. +public sealed record CategorySlice(string Name, string? ColorHex, double Cost) +{ + /// What the slice is (D-42): a category, Uncategorized, or a standing charge no category holds. + public CompositionSliceKind Kind { get; init; } = CompositionSliceKind.Category; + + /// The category, for a category slice. + public int? CategoryId { get; init; } + + /// The standing charge, for a standing-charge slice. + public StandingChargeKey? StandingCharge { get; init; } +} /// The first thing missing before the dashboard can show a cost, in the order they are set up. public enum CostSetupGap @@ -46,13 +98,39 @@ public sealed record CostSetup(bool HasMeters, bool HasCategories, bool HasMembe : CostSetupGap.None; } -/// A point on a monthly cost/consumption trend. -public sealed record TrendPoint(DateOnly Period, double Cost); +/// A point on a monthly cost trend: the bill of one local month (manual costs included). +public sealed record TrendPoint(DateOnly Period, double Cost) +{ + /// How much of the month could be priced (D-38). + public CostStatus CostStatus { get; init; } = CostStatus.Priced; +} /// A row of the "what cost more / less" difference view. +/// As for . +/// The current period's cost. +/// The comparison period's cost. public sealed record DifferenceRow(string Name, double Current, double Previous) { public double Delta => Current - Previous; - public double DeltaPercent => Previous == 0 ? 0 : (Current - Previous) / Math.Abs(Previous) * 100.0; + /// The change in percent of ; 0 when not applicable (D-08, as ). + public double DeltaPercent => Change.Between(Current, Previous, CostKpi.Tolerance).Percent ?? 0; + + /// True when means something: the baseline is a positive amount. + public bool DeltaPercentApplicable => Change.Between(Current, Previous, CostKpi.Tolerance).PercentApplicable; + + /// What the row is: a category, Uncategorized, or a standing charge no category holds. + public CompositionSliceKind Kind { get; init; } = CompositionSliceKind.Category; + + /// The category, for a category row. + public int? CategoryId { get; init; } + + /// The standing charge, for a standing-charge row. + public StandingChargeKey? StandingCharge { get; init; } + + /// + /// For a category row: a view that shares part of the bill with another category (D-42), so its figure overlaps + /// others and the rows do not add up to the bill. + /// + public bool IsOverlappingView { get; init; } } diff --git a/src/Infrastructure/Dashboard/DashboardService.cs b/src/Infrastructure/Dashboard/DashboardService.cs index 60966b7..6e37098 100644 --- a/src/Infrastructure/Dashboard/DashboardService.cs +++ b/src/Infrastructure/Dashboard/DashboardService.cs @@ -1,50 +1,126 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; namespace MeterVault.Infrastructure.Dashboard; /// -/// Read model for the dashboard (SDD §8): overview KPIs with period-over-period deltas, the cost -/// breakdown by category, the "what cost more / less" difference view, and monthly trends. Reads -/// only aggregated cost — never the raw hypertable. Uses a DbContext factory (short-lived context -/// per operation) so it is safe from a Blazor circuit. +/// Read model for the dashboard (SDD §8): the Overview of one resolved period (, brief +/// §7.1) — every type's measures, the bill with its composition, the comparison's bill and the changes over the coverage +/// both share — and the legacy entry points: the KPIs of /api/v1/dashboard/summary (D-45), the cost breakdown by +/// category, the "what cost more / less" difference view and monthly trends. Every figure is the bill of the cost +/// engine (D-34 – D-42), so the Overview, the trend and the breakdown agree with each other and with the API. /// +/// +/// +/// What is counted. The billed meters of each energy type (the grid import when there is one, otherwise +/// household use), standing charges once per scope and per day of service, and every manual cost once on its start +/// day — in the overview, the trend and the breakdown alike (A04, A09). The breakdown is the bill's composition: +/// the disjoint categories, Uncategorized and the standing charges no category holds, so its slices add up to the bill; +/// a category that overlaps another is left out of it (D-42) but still gets its row in the difference view. +/// +/// +/// Windows. The methods keep their legacy date arguments; a range is local days [from, to), and +/// actual figures stop at now (D-04). "Now" is read from the once per call (D-01). +/// +/// public sealed class DashboardService( - IDbContextFactory contextFactory, CostService costService, IOptions? options = null) + IDbContextFactory contextFactory, CostService costService, TimeProvider? time = null) { + /// The order the Overview lists a type's measures in. + private static readonly TotalsMeasure[] MeasureOrder = + [TotalsMeasure.Use, TotalsMeasure.GridImport, TotalsMeasure.Generation, TotalsMeasure.Export, TotalsMeasure.Runtime]; + private readonly IDbContextFactory _contextFactory = contextFactory; - private readonly CostService _costService = costService; + private readonly AnalysisReader _reader = costService.Reader; + private readonly CostReader _costs = costService.Costs; + private readonly TimeProvider _time = time ?? TimeProvider.System; + + private TimeZoneInfo Zone => _costs.Zone; /// - /// The instance timezone: periods are local months, so their bounds are local midnights — the same - /// months consumption is divided and bucketed in. + /// The Overview of one resolved period (brief §7.1): every energy type's measures and every meter's series (so the + /// attention items cover every meter), the bill with its category composition in the same buckets, the comparison's + /// bill priced in the paired buckets (D-06, A-10), and what the page derives from them — per type, per composition + /// slice and per bill line, each change over the coverage both periods share (D-07), and a projection where D-09 + /// allows one. Quantities work without any tariff or category; the bill works without any meter (manual costs). /// - private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone); - - public async Task GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default) + /// The period, resolved once in the instance zone. + /// The bucket size; lets the reader choose (D-05, A-06). + /// What to compare with (D-06). + /// Cancels the reads. + public async Task GetOverviewAsync( + ResolvedPeriod period, BucketSize bucket, ComparisonRequest comparison, CancellationToken cancellationToken = default) { - await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(period); + ArgumentNullException.ThrowIfNull(comparison); - var monthStart = new DateOnly(asOf.Year, asOf.Month, 1); - var prevMonthStart = monthStart.AddMonths(-1); - var yearStart = new DateOnly(asOf.Year, 1, 1); - var prevYearStart = yearStart.AddYears(-1); + var quantities = await _reader.ReadAsync( + new AnalysisRequest(AnalysisScope.Portfolio, period) { Bucket = bucket, Comparison = comparison, IncludeMeterSeries = true }, + cancellationToken).ConfigureAwait(false); + var types = await EnergyTypesAsync(cancellationToken).ConfigureAwait(false); + var resolution = ComparisonResolver.Resolve(period, comparison); - var month = new CostKpi( - await TotalCostAsync(db, monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false), - await TotalCostAsync(db, prevMonthStart, monthStart, cancellationToken).ConfigureAwait(false)); + var request = new CostAnalysisRequest(CostScope.Portfolio, period) { Bucket = bucket, IncludeCategories = true }; + if (quantities.Refusal != AnalysisRefusal.None) + { + // Refused before anything was read: the toolbar offers a coarser bucket; nothing is priced either. + var refused = await _costs.ReadAsync(request with { Plan = quantities.Plan }, cancellationToken).ConfigureAwait(false); + return new DashboardOverview(period, quantities, refused, resolution, null, [], types); + } - var year = new CostKpi( - await TotalCostAsync(db, yearStart, yearStart.AddYears(1), cancellationToken).ConfigureAwait(false), - await TotalCostAsync(db, prevYearStart, yearStart, cancellationToken).ConfigureAwait(false)); + var cost = await _costs.ReadAsync(request with { Plan = quantities.Plan }, cancellationToken).ConfigureAwait(false); - var latest = await LatestMonthCostAsync(db, cancellationToken).ConfigureAwait(false); - return new DashboardSummary(asOf, month, year, latest); + CostAnalysis? previous = null; + IReadOnlyList pairs = []; + if (resolution.IsApplicable && cost.Refusal == CostRefusal.None && cost.Plan.Buckets.Count > 0) + { + pairs = ComparisonResolver.PairBuckets(period, resolution.Period, cost.Plan.Buckets); + var plan = new BucketPlan(cost.Plan.Size, cost.Plan.Size, [.. pairs.Select(p => p.Comparison)], pairs.Count, Refused: false, Suggested: null); + previous = await _costs.ReadAsync( + new CostAnalysisRequest(CostScope.Portfolio, resolution.Period.ToResolvedPeriod(period)) { Plan = plan, IncludeCategories = true }, + cancellationToken).ConfigureAwait(false); + } + + // A first step that is missing (no meters at all, no category that holds anything) gets a setup hint; the + // figures never wait for it. + CostSetup? setup = null; + if (quantities.Classification.Count == 0 + || cost.Composition is { } composition && composition.Categories.All(c => c.Cover.MemberIds.Count == 0 && c.ManualCostIds.Count == 0)) + { + setup = await GetCostSetupAsync(cancellationToken).ConfigureAwait(false); + } + + var names = MeterNamesOf(quantities, cost); + return new DashboardOverview(period, quantities, cost, resolution, previous, pairs, types) + { + Setup = setup, + CostChange = OverviewComparison.Between(cost.Buckets, cost.Total, previous?.Buckets, previous?.Total, pairs), + Projection = OverviewProjection.For(period, cost.Total), + Types = TypeFigures(types, quantities, cost, previous, pairs), + CategoryChanges = CategoryRows(cost, previous, pairs, types, names), + LineChanges = LineRows(cost, previous, pairs, types, names), + MeterNames = names, + }; } + /// The overview KPIs as of now. + public Task GetSummaryAsync(DateTimeOffset now, CancellationToken cancellationToken = default) => + SummaryAsync(PeriodResolver.LocalDate(now, Zone), now.ToUniversalTime(), cancellationToken); + + /// + /// The overview KPIs as of the local date : its calendar month and year up to now (or up to + /// the end of when that lies in the past), against the whole previous month and year. + /// + public Task GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default) => + SummaryAsync(asOf, AsOfInstant(asOf), cancellationToken); + /// /// Which part of the cost setup exists, so an empty dashboard can name the step that is missing /// instead of listing every admin page. Existence checks only. @@ -63,101 +139,441 @@ public sealed class DashboardService( HasManualCosts: await db.ManualCosts.AnyAsync(cancellationToken).ConfigureAwait(false)); } + /// + /// The bill of [from, to) by its composition (D-42): the disjoint categories, Uncategorized and the standing + /// charges no category holds, each with a cost of at least half a cent either way, largest first. The slices add up to + /// the bill; a negative slice (a feed-in credit) is kept. + /// public async Task> GetCategoryBreakdownAsync( DateOnly from, DateOnly to, CancellationToken cancellationToken = default) { - await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - var categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false); - - var slices = new List(); - foreach (var category in categories) + var period = LegacyPeriods.FromDates(from, to, _time.GetUtcNow(), Zone); + var bill = await CompositionAsync(period, cancellationToken).ConfigureAwait(false); + if (bill?.Composition is not { } composition) { - var rollup = await _costService - .GetCategoryCostsAsync(category.Id, ToUtc(from), ToUtc(to), cancellationToken).ConfigureAwait(false); - var total = rollup.Sum(r => r.Cost); - if (Math.Abs(total) > 0.005) + return []; + } + + var names = await ScopeNamesAsync(cancellationToken).ConfigureAwait(false); + var categories = composition.Categories.ToDictionary(c => c.CategoryId); + var slices = new List(); + foreach (var slice in composition.Slices) + { + if (slice.Total.Cost is not { } cost || Math.Abs(cost) <= CostKpi.Tolerance) { - slices.Add(new CategorySlice(category.Name, category.ColorHex, total)); + continue; } + + var category = slice.CategoryId is { } id ? categories.GetValueOrDefault(id) : null; + slices.Add(new CategorySlice(NameOf(slice.Kind, category, slice.StandingCharge, names), category?.ColorHex, cost) + { + Kind = slice.Kind, + CategoryId = slice.CategoryId, + StandingCharge = slice.StandingCharge, + }); } return [.. slices.OrderByDescending(s => s.Cost)]; } + /// + /// What cost more or less: every category (overlapping views flagged), Uncategorized and the standing charges no + /// category holds, over the months from up to the month of + /// (cut at now), against the same stretch from . A current period cut at now is + /// compared with the same elapsed part of a year before (D-06) when is a year + /// earlier; otherwise with the whole shifted months. Rows with neither figure are left out; largest change first. + /// public async Task> GetCategoryDifferenceAsync( DateOnly currentStart, DateOnly previousStart, DateOnly span, CancellationToken cancellationToken = default) { - var months = ((span.Year - currentStart.Year) * 12) + span.Month - currentStart.Month; - var currentEnd = currentStart.AddMonths(Math.Max(1, months)); - var previousEnd = previousStart.AddMonths(Math.Max(1, months)); + var months = Math.Max(1, ((span.Year - currentStart.Year) * 12) + span.Month - currentStart.Month); + var now = _time.GetUtcNow(); + var current = LegacyPeriods.FromDates(currentStart, currentStart.AddMonths(months), now, Zone); + var previous = ComparisonPeriod(current, previousStart, months, now); - await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - var categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false); + var currentBill = await CompositionAsync(current, cancellationToken).ConfigureAwait(false); + var previousBill = await CompositionAsync(previous, cancellationToken).ConfigureAwait(false); + var names = await ScopeNamesAsync(cancellationToken).ConfigureAwait(false); - var rows = new List(); - foreach (var category in categories) + var rows = new Dictionary<(CompositionSliceKind Kind, int? Category, StandingChargeKey? Row), DifferenceRow>(); + void Add(CategoryComposition? composition, bool isCurrent) { - var current = (await _costService.GetCategoryCostsAsync(category.Id, ToUtc(currentStart), ToUtc(currentEnd), cancellationToken).ConfigureAwait(false)).Sum(r => r.Cost); - var previous = (await _costService.GetCategoryCostsAsync(category.Id, ToUtc(previousStart), ToUtc(previousEnd), cancellationToken).ConfigureAwait(false)).Sum(r => r.Cost); - if (Math.Abs(current) > 0.005 || Math.Abs(previous) > 0.005) + if (composition is null) { - rows.Add(new DifferenceRow(category.Name, current, previous)); + return; + } + + foreach (var category in composition.Categories) + { + Merge((CompositionSliceKind.Category, category.CategoryId, null), category.Total.Cost, isCurrent, () => + new DifferenceRow(category.Name, 0, 0) { CategoryId = category.CategoryId, IsOverlappingView = category.IsOverlappingView }); + } + + foreach (var slice in composition.Slices.Where(s => s.Kind != CompositionSliceKind.Category)) + { + Merge((slice.Kind, null, slice.StandingCharge), slice.Total.Cost, isCurrent, () => + new DifferenceRow(NameOf(slice.Kind, null, slice.StandingCharge, names), 0, 0) { Kind = slice.Kind, StandingCharge = slice.StandingCharge }); } } - return [.. rows.OrderByDescending(r => Math.Abs(r.Delta))]; + void Merge((CompositionSliceKind, int?, StandingChargeKey?) key, double? cost, bool isCurrent, Func create) + { + var row = rows.TryGetValue(key, out var existing) ? existing : create(); + rows[key] = isCurrent ? row with { Current = cost ?? 0 } : row with { Previous = cost ?? 0 }; + } + + Add(currentBill?.Composition, isCurrent: true); + Add(previousBill?.Composition, isCurrent: false); + + return [.. rows.Values + .Where(r => Math.Abs(r.Current) > CostKpi.Tolerance || Math.Abs(r.Previous) > CostKpi.Tolerance) + .OrderByDescending(r => Math.Abs(r.Delta))]; } + /// + /// The bill of each local month of [from, to), up to now — manual costs included, so the trend and the + /// overview agree (A09). A month with nothing measured and nothing charged is left out rather than drawn as a zero + /// (a measured zero is kept); a range too long for months is charted by year. + /// public async Task> GetMonthlyTrendAsync( DateOnly from, DateOnly to, CancellationToken cancellationToken = default) { - await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - var totals = new Dictionary(); - foreach (var meterId in await ActiveMeterIdsAsync(db, cancellationToken).ConfigureAwait(false)) + var period = LegacyPeriods.FromDates(from, to, _time.GetUtcNow(), Zone); + var size = BucketPlanner.CountBuckets(period, BucketSize.Month) <= AnalysisLimits.MaxPoints ? BucketSize.Month : BucketSize.Year; + var plan = BucketPlanner.Plan(period, size); + if (plan.Refused || plan.Buckets.Count == 0) { - foreach (var bucket in await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false)) + return []; + } + + var bill = await _costs.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, period) { Plan = plan }, cancellationToken) + .ConfigureAwait(false); + + var points = new List(); + for (var i = 0; i < plan.Buckets.Count && i < bill.Buckets.Count; i++) + { + // A month with nothing measured and nothing charged is not a point — a standing charge configured for later + // years makes the engine call such a month a known 0, which on a trend would read as a cheap month. + var figure = bill.Buckets[i]; + if (figure.Cost is { } cost && (figure.Availability != BucketStatus.Missing || Math.Abs(cost) > CostKpi.Tolerance)) { - totals[bucket.Period] = totals.GetValueOrDefault(bucket.Period) + bucket.Cost; + points.Add(new TrendPoint(LegacyPeriods.KeyOf(plan.Buckets[i]), cost) { CostStatus = figure.Status }); } } - return [.. totals.OrderBy(kv => kv.Key).Select(kv => new TrendPoint(kv.Key, kv.Value))]; + return points; } - private async Task TotalCostAsync(MeterVaultDbContext db, DateOnly from, DateOnly to, CancellationToken cancellationToken) + private async Task SummaryAsync(DateOnly asOf, DateTimeOffset now, CancellationToken cancellationToken) { - double total = 0; - foreach (var meterId in await ActiveMeterIdsAsync(db, cancellationToken).ConfigureAwait(false)) + var monthStart = new DateOnly(asOf.Year, asOf.Month, 1); + var yearStart = new DateOnly(asOf.Year, 1, 1); + + var month = new CostKpi( + await TotalAsync(monthStart, monthStart.AddMonths(1), now, cancellationToken).ConfigureAwait(false), + await TotalAsync(monthStart.AddMonths(-1), monthStart, now, cancellationToken).ConfigureAwait(false)); + var year = new CostKpi( + await TotalAsync(yearStart, yearStart.AddYears(1), now, cancellationToken).ConfigureAwait(false), + await TotalAsync(yearStart.AddYears(-1), yearStart, now, cancellationToken).ConfigureAwait(false)); + + // The latest month with data (D-19): billed meters and manual costs alike, never a month after now. + var availability = await _costs.GetAvailabilityAsync(CostScope.Portfolio, now, cancellationToken).ConfigureAwait(false); + LatestMonthWithData? latest = null; + double latestCost = 0; + if (availability.Latest is { } period) { - var costs = await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false); - total += costs.Sum(c => c.Cost); + latest = new LatestMonthWithData(period.Month, period.Basis); + latestCost = await TotalAsync(period.Month, period.Month.AddMonths(1), now, cancellationToken).ConfigureAwait(false); } - var manual = await db.ManualCosts.AsNoTracking() - .Where(c => c.PeriodStart >= from && c.PeriodStart < to) - .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false); - - return total + (manual ?? 0); + return new DashboardSummary(asOf, month, year, latestCost) { LatestMonth = latest }; } - private async Task LatestMonthCostAsync(MeterVaultDbContext db, CancellationToken cancellationToken) + /// The bill of the local days [from, to) up to ; 0 when nothing could be priced. + private async Task TotalAsync(DateOnly from, DateOnly to, DateTimeOffset now, CancellationToken cancellationToken) { - var latest = await db.Consumption.AsNoTracking() - .OrderByDescending(c => c.Time) - .Select(c => (DateTimeOffset?)c.Time) - .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); - if (latest is null) + var period = LegacyPeriods.FromDates(from, to, now, Zone); + var plan = LegacyPeriods.WholePeriodPlan(period); + if (plan.Buckets.Count == 0) { return 0; } - var local = TimeZoneInfo.ConvertTime(latest.Value, _zone); - var monthStart = new DateOnly(local.Year, local.Month, 1); - return await TotalCostAsync(db, monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false); + var bill = await _costs.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, period) { Plan = plan }, cancellationToken) + .ConfigureAwait(false); + return bill.Total.Cost ?? 0; } - private static async Task> ActiveMeterIdsAsync(MeterVaultDbContext db, CancellationToken cancellationToken) => - await db.Meters.AsNoTracking().Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false); + /// The bill of a period with its category composition, in one bucket; null for a period with nothing to price. + private async Task CompositionAsync(ResolvedPeriod period, CancellationToken cancellationToken) + { + var plan = LegacyPeriods.WholePeriodPlan(period); + if (plan.Buckets.Count == 0) + { + return null; + } - private DateTimeOffset ToUtc(DateOnly date) => InstanceTimeZone.StartOf(date, _zone); + return await _costs.ReadAsync( + new CostAnalysisRequest(CostScope.Portfolio, period) { Plan = plan, IncludeCategories = true }, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// The period the difference view compares with: the same elapsed part a year before when the current period is cut + /// at now and is a year earlier (D-06), otherwise the whole shifted months. + /// + private ResolvedPeriod ComparisonPeriod(ResolvedPeriod current, DateOnly previousStart, int months, DateTimeOffset now) + { + if (current.IsToDate && previousStart == current.FirstDay.AddYears(-1)) + { + var resolution = ComparisonResolver.Resolve(current, new ComparisonRequest(ComparisonKind.PreviousYear)); + if (resolution.Period is { } comparison) + { + return comparison.ToResolvedPeriod(current); + } + } + + return LegacyPeriods.FromDates(previousStart, previousStart.AddMonths(months), now, Zone); + } + + /// + /// The instant a legacy "as of" date stands for: now, or — for a date already past — the end of that day, so the + /// figures are those the dashboard showed at the close of it. A date after today cannot see ahead: now. + /// + private DateTimeOffset AsOfInstant(DateOnly asOf) + { + var now = _time.GetUtcNow().ToUniversalTime(); + var endOfDay = InstanceTimeZone.StartOf(asOf.AddDays(1), Zone); + return endOfDay < now ? endOfDay : now; + } + + /// The names of every energy type and meter, by scope and id (user data), for standing-charge rows. + private async Task> ScopeNamesAsync(CancellationToken cancellationToken) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var types = await db.EnergyTypes.AsNoTracking().Select(t => new { t.Id, t.DisplayName }).ToListAsync(cancellationToken).ConfigureAwait(false); + var meters = await db.Meters.AsNoTracking().Select(m => new { m.Id, m.Name }).ToListAsync(cancellationToken).ConfigureAwait(false); + var names = new Dictionary<(TariffScope Scope, int Id), string>(); + foreach (var type in types) + { + names[(TariffScope.EnergyType, type.Id)] = type.DisplayName; + } + + foreach (var meter in meters) + { + names[(TariffScope.Meter, meter.Id)] = meter.Name; + } + + return names; + } + + /// Every energy type with its name and meter count, ordered by id (the navigation's order). + private async Task> EnergyTypesAsync(CancellationToken cancellationToken) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var types = await db.EnergyTypes.AsNoTracking() + .OrderBy(t => t.Id) + .Select(t => new { t.Id, t.DisplayName, Meters = t.Meters.Count }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + return [.. types.Select(t => new OverviewEnergyType(t.Id, t.DisplayName, t.Meters))]; + } + + /// Every meter name the results carry (user data). + private static Dictionary MeterNamesOf(AnalysisResult quantities, CostAnalysis cost) + { + var names = new Dictionary(); + foreach (var entry in quantities.Classification) + { + names.TryAdd(entry.MeterId, entry.Name); + } + + foreach (var series in quantities.Series) + { + if (series.MeterId is { } id) + { + names.TryAdd(id, series.Name); + } + } + + foreach (var line in cost.Lines) + { + names.TryAdd(line.MeterId, line.Name); + } + + return names; + } + + /// Each energy type with meters: its measures, its part of both bills and the change between them. + private static IReadOnlyList TypeFigures( + IReadOnlyList types, AnalysisResult quantities, CostAnalysis cost, CostAnalysis? previous, IReadOnlyList pairs) + { + var figures = new List(); + foreach (var type in types.Where(t => t.HasMeters)) + { + var measures = quantities.Measures + .Where(m => m.EnergyTypeId == type.Id && m.Key.Measure is not null) + .OrderBy(m => Array.IndexOf(MeasureOrder, m.Key.Measure!.Value)) + .ThenBy(m => m.Unit, StringComparer.Ordinal) + .ToList(); + var now = cost.EnergyTypes.FirstOrDefault(t => t.EnergyTypeId == type.Id); + var before = previous?.EnergyTypes.FirstOrDefault(t => t.EnergyTypeId == type.Id); + var change = now is null || before is null + ? CostChange.NoComparison + : OverviewComparison.Between(now.Buckets, now.Total, before.Buckets, before.Total, pairs); + var freshness = FreshnessRules.Combine(quantities.Series + .Where(s => s.EnergyTypeId == type.Id && s.Basis == SeriesBasis.Physical) + .Select(s => s.Freshness)); + figures.Add(new OverviewTypeFigures(type, measures, now, before, change, freshness)); + } + + return figures; + } + + /// The composition slices (D-42) of the period and the comparison, matched by what they are. + private static IReadOnlyList CategoryRows( + CostAnalysis cost, CostAnalysis? previous, IReadOnlyList pairs, IReadOnlyList types, Dictionary names) + { + if (cost.Composition is not { } composition) + { + return []; + } + + var before = previous?.Composition; + var categories = composition.Categories.Concat(before?.Categories ?? []).DistinctBy(c => c.CategoryId).ToDictionary(c => c.CategoryId); + var keys = composition.Slices.Select(Key).Concat(before?.Slices.Select(Key) ?? []).Distinct().ToList(); + + var rows = new List(); + foreach (var key in keys) + { + var now = composition.Slices.FirstOrDefault(s => Key(s) == key); + var then = before?.Slices.FirstOrDefault(s => Key(s) == key); + var kind = key.Kind switch + { + CompositionSliceKind.Category => OverviewRowKind.Category, + CompositionSliceKind.Uncategorized => OverviewRowKind.Uncategorized, + _ => OverviewRowKind.StandingCharge, + }; + var category = key.CategoryId is { } id ? categories.GetValueOrDefault(id) : null; + var name = kind switch + { + OverviewRowKind.Category => category?.Name ?? string.Empty, + OverviewRowKind.StandingCharge => StandingChargeName(key.Charge, types, names), + _ => string.Empty, + }; + rows.Add(Row(kind, name, now?.Buckets, now?.Total, before is null ? null : then?.Buckets ?? [], before is null ? null : then?.Total, pairs) with + { + CategoryId = key.CategoryId, + ColorHex = category?.ColorHex, + StandingCharge = key.Charge, + EnergyTypeId = key.Charge is { Scope: TariffScope.EnergyType, ScopeId: { } type } ? type : null, + MeterId = key.Charge is { Scope: TariffScope.Meter, ScopeId: { } meter } ? meter : null, + }); + } + + return Ranked(rows); + + static (CompositionSliceKind Kind, int? CategoryId, StandingChargeKey? Charge) Key(CompositionSlice slice) => + (slice.Kind, slice.CategoryId, slice.StandingCharge); + } + + /// The bill's lines, standing charges and manual costs of the period and the comparison, matched by what they are. + private static IReadOnlyList LineRows( + CostAnalysis cost, CostAnalysis? previous, IReadOnlyList pairs, IReadOnlyList types, Dictionary names) + { + var rows = new List(); + var lineKeys = cost.Lines.Select(LineKey).Concat(previous?.Lines.Select(LineKey) ?? []).Distinct().ToList(); + foreach (var key in lineKeys) + { + var now = cost.Lines.FirstOrDefault(l => LineKey(l) == key); + var then = previous?.Lines.FirstOrDefault(l => LineKey(l) == key); + var line = now ?? then!; + rows.Add(Row(OverviewRowKind.Line, line.Name, now?.Buckets, now?.Total, previous is null ? null : then?.Buckets ?? [], previous is null ? null : then?.Total, pairs) with + { + MeterId = key.MeterId, + LineKind = key.Kind, + ForMeterId = key.ForMeterId, + EnergyTypeId = line.EnergyTypeId, + }); + } + + var chargeKeys = cost.StandingCharges.Select(ChargeKey).Concat(previous?.StandingCharges.Select(ChargeKey) ?? []).Distinct().ToList(); + foreach (var key in chargeKeys) + { + var now = cost.StandingCharges.FirstOrDefault(c => ChargeKey(c) == key); + var then = previous?.StandingCharges.FirstOrDefault(c => ChargeKey(c) == key); + rows.Add(Row(OverviewRowKind.StandingCharge, StandingChargeName(key, types, names), now?.Buckets, now?.Total, previous is null ? null : then?.Buckets ?? [], previous is null ? null : then?.Total, pairs) with + { + StandingCharge = key, + EnergyTypeId = key is { Scope: TariffScope.EnergyType, ScopeId: { } type } ? type : null, + MeterId = key is { Scope: TariffScope.Meter, ScopeId: { } meter } ? meter : null, + }); + } + + if (cost.ManualCosts.Total.Cost is not null || previous?.ManualCosts.Total.Cost is not null) + { + rows.Add(Row( + OverviewRowKind.ManualCosts, string.Empty, cost.ManualCosts.Buckets, cost.ManualCosts.Total, previous?.ManualCosts.Buckets, previous?.ManualCosts.Total, pairs)); + } + + return Ranked(rows); + + static (int MeterId, BillLineKind Kind, int? ForMeterId) LineKey(CostLineFigure line) => (line.MeterId, line.Kind, line.ForMeterId); + + static StandingChargeKey ChargeKey(StandingChargeFigure charge) => new(charge.Scope, charge.ScopeId); + } + + /// + /// A change row. null means nothing is compared; an empty list with a null total, that + /// the row did not occur in the comparison. + /// + private static OverviewChangeRow Row( + OverviewRowKind kind, + string name, + IReadOnlyList? buckets, + CostAmount? total, + IReadOnlyList? previousBuckets, + CostAmount? previousTotal, + IReadOnlyList pairs) + { + var change = previousBuckets is null || total is null || previousTotal is null + ? CostChange.NoComparison + : OverviewComparison.Between(buckets, total, previousBuckets, previousTotal, pairs); + return new OverviewChangeRow(kind, name, total, previousTotal, change); + } + + /// + /// Rows with something to show — a known figure on either side — ranked by the size of their change, then by their + /// current figure; rows without any known figure (a line that is not priced) are left to the composition panel. + /// + private static IReadOnlyList Ranked(List rows) => + [ + .. rows + .Where(r => r.Current?.Cost is not null || r.Previous?.Cost is not null) + .OrderByDescending(r => r.Magnitude is not null) + .ThenByDescending(r => r.Magnitude ?? 0) + .ThenByDescending(r => Math.Abs(r.Current?.Cost ?? 0)) + .ThenBy(r => r.Kind) + .ThenBy(r => r.Name, StringComparer.CurrentCulture), + ]; + + /// A standing charge's name: its energy type's or meter's (user data); empty for the global one. + private static string StandingChargeName(StandingChargeKey? key, IReadOnlyList types, Dictionary names) => key switch + { + { Scope: TariffScope.EnergyType, ScopeId: { } type } => types.FirstOrDefault(t => t.Id == type)?.Name ?? string.Empty, + { Scope: TariffScope.Meter, ScopeId: { } meter } => names.GetValueOrDefault(meter, string.Empty), + _ => string.Empty, + }; + + /// + /// A slice's name: the category's; for a standing charge, its energy type's or — for a meter's own fee (A-18) — its + /// meter's (user data); else empty for the page to name. + /// + private static string NameOf( + CompositionSliceKind kind, CategoryCostFigure? category, StandingChargeKey? row, Dictionary<(TariffScope Scope, int Id), string> names) => kind switch + { + CompositionSliceKind.Category => category?.Name ?? string.Empty, + CompositionSliceKind.StandingCharge when row is { Scope: TariffScope.EnergyType or TariffScope.Meter, ScopeId: { } id } => + names.GetValueOrDefault((row.Scope, id), string.Empty), + _ => string.Empty, + }; } diff --git a/src/Infrastructure/Dashboard/FlowModels.cs b/src/Infrastructure/Dashboard/FlowModels.cs index 55c5fb0..e69674f 100644 --- a/src/Infrastructure/Dashboard/FlowModels.cs +++ b/src/Infrastructure/Dashboard/FlowModels.cs @@ -1,3 +1,6 @@ +using MeterVault.Core.Analysis; +using MeterVault.Infrastructure.Analysis; + namespace MeterVault.Infrastructure.Dashboard; /// A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder. @@ -6,15 +9,73 @@ namespace MeterVault.Infrastructure.Dashboard; /// name. The surrounding wording ("Other (…)") is the UI's to supply, because it is the only layer /// that knows the reader's language; see Flow_OtherNode. /// -public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId); +/// +/// The size the node is drawn with: the meter's canonical period total (D-30), never below zero — a ribbon cannot be +/// negative. The signed total and its status are in . +/// +public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId) +{ + /// + /// How far the value can be trusted (D-14): a meter's period-total status; for a remainder, partial when its parent + /// or any sub-meter is not fully available. A missing, pending or invalid meter is drawn at zero. + /// + public BucketStatus Status { get; init; } = BucketStatus.Available; -/// A directed flow edge with the quantity that flows along it, in the energy type's base unit. -public sealed record FlowLink(string From, string To, double Value); + /// True for a virtual meter: its value is its formula over its sources (D-27), not a measurement. + public bool IsVirtual { get; init; } +} + +/// A directed flow edge with the quantity that flows along it, in the graph's unit. +public sealed record FlowLink(string From, string To, double Value) +{ + /// + /// A calculation dependency rather than a measured flow (D-30): the edge from a source into a virtual sum, drawn at + /// what the source put into the sum. + /// + public bool IsCalculated { get; init; } + + /// + /// The value is an estimate (D-30): the downstream meter has several upstream meters, and its consumption is split + /// across them in proportion to their own values, or the links were cut down to fit the upstream meter. + /// + public bool IsEstimated { get; init; } + + /// The downstream values added up to more than the upstream meter measured, so the edge was capped at it. + public bool IsCapped { get; init; } +} /// -/// The per-energy-type flow graph (SDD-style topology view): meters as nodes sized by consumption, -/// directed edges sized by the flow along each configured link, plus "Other" remainders where an -/// upstream meter's flow isn't fully accounted for by its sub-meters. Rendered as a Sankey diagram. +/// One meter of the energy type as the flow read it — every meter, including those the diagram cannot draw (a +/// virtual meter that is not a pure sum, a meter in another unit): the flow's table view (D-30). +/// +/// The meter. +/// Its name (user data). +/// The canonical period total (signed); null when there is no number (missing, pending, invalid). +/// The period total's status (D-14). +/// Why the total is not a plain available number. +/// What the total measures (D-20). +/// The total's normalized unit (D-20). +/// Where the total comes from: rollups, a formula, or a legacy virtual meter's implied sum. +/// True when the meter is a node of the diagram. +public sealed record FlowMeter( + int MeterId, + string Name, + double? Value, + BucketStatus Status, + ValueIssue Issue, + QuantityKind Kind, + string Unit, + SeriesBasis Basis, + bool InDiagram) +{ + public bool IsVirtual => Basis is SeriesBasis.Virtual or SeriesBasis.LegacyVirtual; +} + +/// +/// The per-energy-type flow graph (SDD-style topology view): meters as nodes sized by their canonical +/// period totals, directed edges sized by the flow along each configured link — and, into a virtual sum, by +/// its calculation dependencies — plus "Other" remainders where an upstream meter's flow isn't fully +/// accounted for by its sub-meters. Rendered as a Sankey diagram. /// public sealed record FlowGraph( short EnergyTypeId, @@ -28,4 +89,13 @@ public sealed record FlowGraph( /// True when meters are actually chained (not just a flat, unlinked list). public bool HasChain => Links.Count > 0; + + /// Every meter of the type with its canonical total, drawn or not (D-30's table view), by id. + public IReadOnlyList Meters { get; init; } = []; + + /// What qualifies the values: legacy or invalid virtual definitions, meters being rebuilt, rows after now. + public IReadOnlyList Problems { get; init; } = []; + + /// The meter's entry in , or null. + public FlowMeter? MeterFor(int meterId) => Meters.FirstOrDefault(m => m.MeterId == meterId); } diff --git a/src/Infrastructure/Dashboard/FlowService.cs b/src/Infrastructure/Dashboard/FlowService.cs index 9ee83db..a94020f 100644 --- a/src/Infrastructure/Dashboard/FlowService.cs +++ b/src/Infrastructure/Dashboard/FlowService.cs @@ -1,4 +1,7 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -7,159 +10,361 @@ using Microsoft.Extensions.Options; namespace MeterVault.Infrastructure.Dashboard; /// -/// Builds the per-energy-type flow graph (Sankey) from the meter topology () -/// and consumption over a period. Each meter is a node sized by its consumption; each configured -/// edge carries the downstream meter's consumption (split proportionally when a meter has several -/// upstreams); the unaccounted remainder under a meter becomes a synthetic "Other" node. Nothing is -/// hardcoded per energy type — it works for electricity, water, gas, … alike. DbContext factory -/// keeps it Blazor-circuit safe. +/// Builds the per-energy-type flow graph (Sankey) from the meter topology () and the +/// canonical period totals of the shared analysis reader (D-30). Each meter is a node sized by its own period total — +/// a virtual meter's by its formula (D-27), never by adding up its links; each configured edge carries the downstream +/// meter's total (split proportionally when a meter has several upstreams, which is an estimate), capped at what the +/// upstream meter measured; the unaccounted remainder under a meter becomes a synthetic "Other" node. Nothing is +/// hardcoded per energy type — it works for electricity, water, gas, … alike. /// +/// +/// +/// A virtual meter whose formula is a pure sum (m1 + m2) is drawn with its calculation dependencies as its +/// incoming edges, each at what that source put into the sum, and marked calculated: those, not whatever links point +/// at it, are what it is. Any other virtual meter — a difference, a ratio, an invalid or unconfigured one — has no +/// place in a flow of non-negative ribbons, so it appears only in , the table view, with +/// its signed value and status. So does a meter measured in another unit than the diagram's: a flow never adds units. +/// +/// +/// A meter without a number for the period (no data, still being rebuilt, an invalid formula) is drawn at zero and +/// named as such in the table, never shown as a measured zero. An "Other" remainder is only drawn under a meter +/// whose sub-meters all have a number: an unknown sub-meter would otherwise turn into part of "Other". +/// +/// +/// The reader reads only the rollups of the type's meters (and of whatever other meter a formula names), once per +/// table; nothing here scans consumption. It never reads the clock: the date overload captures "now" once, +/// through the injected (D-01). DbContext factory keeps it Blazor-circuit safe. +/// +/// public sealed class FlowService( - IDbContextFactory contextFactory, IOptions? options = null) + IDbContextFactory contextFactory, IOptions? options = null, TimeProvider? time = null) { private const double Epsilon = 0.01; + private const string OtherColor = "#78909C"; private readonly IDbContextFactory _contextFactory = contextFactory; + private readonly AnalysisReader _reader = new(contextFactory, options); + private readonly TimeProvider _time = time ?? TimeProvider.System; - /// The instance timezone a requested date range starts and ends in. - private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone); + /// The instance zone a requested date range starts and ends in. + public TimeZoneInfo Zone => _reader.Zone; - public async Task GetFlowAsync(short energyTypeId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default) + /// + /// The flow of over the local days [from, to) is + /// exclusive — up to now: a range reaching past today counts actuals only (D-04). + /// + public Task GetFlowAsync(short energyTypeId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default) + { + var first = from < PeriodResolver.MinSupportedDate ? PeriodResolver.MinSupportedDate : from; + var last = to.AddDays(-1); + last = last > PeriodResolver.MaxSupportedDate ? PeriodResolver.MaxSupportedDate : last; + var period = PeriodResolver.IsValidCustomRange(first, last) + ? PeriodResolver.Resolve(PeriodPreset.Custom, first, last, _time.GetUtcNow(), Zone) + : null; + return GetFlowCoreAsync(energyTypeId, period, cancellationToken); + } + + /// The flow of over a period resolved by the caller in the instance zone. + public Task GetFlowAsync(short energyTypeId, ResolvedPeriod period, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(period); + + return GetFlowCoreAsync(energyTypeId, period, cancellationToken); + } + + /// + /// The flow of drawn from an analysis result the caller already read — the energy + /// type page, which reads the type once ( with + /// ) and shows the very same totals in its flow, its meter list and + /// its history, without reading the rollups a second time. Only the meters the result has a series for are drawn or + /// listed; a result that was refused draws nothing. + /// + public async Task FromResultAsync(short energyTypeId, AnalysisResult result, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(result); + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var energyType = await db.EnergyTypes.AsNoTracking().FirstOrDefaultAsync(t => t.Id == energyTypeId, cancellationToken).ConfigureAwait(false); + if (energyType is null) + { + return new FlowGraph(energyTypeId, "", "", 0, [], []); + } + + var read = result.Series.Where(s => s.MeterId is not null).Select(s => s.MeterId!.Value).ToHashSet(); + var catalog = await AnalysisCatalog.LoadAsync(db, Zone, cancellationToken).ConfigureAwait(false); + var meters = catalog.Meters.Values.Where(m => m.EnergyTypeId == energyTypeId && read.Contains(m.Id)).OrderBy(m => m.Id).ToList(); + return meters.Count == 0 + ? new FlowGraph(energyTypeId, energyType.DisplayName, energyType.BaseUnit, 0, [], []) { Problems = result.Problems } + : new Builder(energyType, catalog, meters, result).Build(); + } + + private async Task GetFlowCoreAsync(short energyTypeId, ResolvedPeriod? period, CancellationToken cancellationToken) { await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); var energyType = await db.EnergyTypes.AsNoTracking().FirstOrDefaultAsync(t => t.Id == energyTypeId, cancellationToken).ConfigureAwait(false); - var meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId).ToListAsync(cancellationToken).ConfigureAwait(false); - if (energyType is null || meters.Count == 0) + if (energyType is null) { - return new FlowGraph(energyTypeId, energyType?.DisplayName ?? "", energyType?.BaseUnit ?? "", 0, [], []); + return new FlowGraph(energyTypeId, "", "", 0, [], []); } - var meterIds = meters.Select(m => m.Id).ToHashSet(); - var fromUtc = InstanceTimeZone.StartOf(from, _zone); - var toUtc = InstanceTimeZone.StartOf(to, _zone); - - // A meter's flow value is its throughput: consumption OR generation output — so a generation - // meter (solar) can act as a source feeding downstream meters (grid + solar → house). A meter - // is normally one kind, so summing both kinds is that meter's flow. Negatives (savings/balance - // virtual meters) are clamped to 0 — a flow ribbon can't be negative. - var sums = await db.Consumption.AsNoTracking() - .Where(c => c.Time >= fromUtc && c.Time < toUtc) - .GroupBy(c => c.MeterId) - .Select(g => new { MeterId = g.Key, Total = g.Sum(x => x.Amount) }) - .ToListAsync(cancellationToken).ConfigureAwait(false); - var value = sums.Where(s => meterIds.Contains(s.MeterId)).ToDictionary(s => s.MeterId, s => Math.Max(0, s.Total)); - double V(int id) => value.GetValueOrDefault(id); - - var links = await db.MeterLinks.AsNoTracking() - .Where(l => meterIds.Contains(l.FromMeterId) && meterIds.Contains(l.ToMeterId)) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - var parents = meters.ToDictionary(m => m.Id, _ => new List()); - var children = meters.ToDictionary(m => m.Id, _ => new List()); - foreach (var link in links) + var catalog = await AnalysisCatalog.LoadAsync(db, Zone, cancellationToken).ConfigureAwait(false); + var meters = catalog.Meters.Values.Where(m => m.EnergyTypeId == energyTypeId).OrderBy(m => m.Id).ToList(); + if (meters.Count == 0 || period is null) { - children[link.FromMeterId].Add(link.ToMeterId); - parents[link.ToMeterId].Add(link.FromMeterId); + return new FlowGraph(energyTypeId, energyType.DisplayName, energyType.BaseUnit, 0, [], []); } - var depth = ComputeDepths(meters.Select(m => m.Id).ToList(), parents, children); - - // Aggregate ("sum") meters: a Virtual-mode meter has no measurements of its own — in the flow - // it is the sum of its upstream meters (e.g. "Sum Solar" = Solar 1 + Solar 2). Resolve these in - // topological (depth) order so each aggregate sees its already-resolved upstream values. - var aggregates = meters.Where(m => m.Mode == MeterMode.Virtual).Select(m => m.Id).ToHashSet(); - foreach (var id in meters.Select(m => m.Id).OrderBy(id => depth.GetValueOrDefault(id))) + // Only period totals are needed; year buckets let physical meters read the month table, and the totals do not + // depend on the buckets. No freshness and no cost availability: nothing here reads raw readings or manual costs. + var request = new AnalysisRequest(AnalysisScope.ForMeters(meters.Select(m => m.Id)), period) { - if (aggregates.Contains(id)) - { - value[id] = parents[id].Sum(V); - } - } - - // Link value: a child's consumption flows in from its parent(s); with several parents it is - // split proportionally to the parents' own consumption (equal split if those are all zero). - var flowLinks = new List(); - var outgoingByParent = meters.ToDictionary(m => m.Id, _ => 0d); - foreach (var (childId, parentIds) in parents) - { - if (parentIds.Count == 0) - { - continue; - } - - var parentTotal = parentIds.Sum(V); - foreach (var parentId in parentIds) - { - var share = parentIds.Count == 1 ? 1d - : parentTotal > Epsilon ? V(parentId) / parentTotal - : 1d / parentIds.Count; - var linkValue = V(childId) * share; - if (linkValue > Epsilon) - { - flowLinks.Add(new FlowLink(NodeId(parentId), NodeId(childId), linkValue)); - outgoingByParent[parentId] += linkValue; - } - } - } - - var nodes = new List(); - foreach (var meter in meters) - { - // Keep a meter node if it carries flow or participates in the topology. - if (V(meter.Id) <= Epsilon && children[meter.Id].Count == 0 && parents[meter.Id].Count == 0) - { - continue; - } - - nodes.Add(new FlowNode(NodeId(meter.Id), meter.Name, V(meter.Id), depth.GetValueOrDefault(meter.Id), energyType.ColorHex, false, meter.Id)); - - // Unaccounted remainder under a meter with sub-meters → "Other". - if (children[meter.Id].Count > 0) - { - var remainder = V(meter.Id) - outgoingByParent[meter.Id]; - if (remainder > Epsilon) - { - var otherId = $"other{meter.Id}"; - // Just the parent's name: the "Other (…)" phrasing is added by the UI, which is - // where the reader's language is known. - nodes.Add(new FlowNode(otherId, meter.Name, remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null)); - flowLinks.Add(new FlowLink(NodeId(meter.Id), otherId, remainder)); - } - } - } - - var total = meters.Where(m => parents[m.Id].Count == 0).Sum(m => V(m.Id)); - return new FlowGraph(energyTypeId, energyType.DisplayName, energyType.BaseUnit, total, nodes, flowLinks); - } - - /// Longest-path depth from the roots (Kahn topological relaxation); robust to stray cycles. - private static Dictionary ComputeDepths( - List ids, Dictionary> parents, Dictionary> children) - { - var depth = ids.ToDictionary(id => id, _ => 0); - var indegree = ids.ToDictionary(id => id, id => parents[id].Count); - var queue = new Queue(ids.Where(id => indegree[id] == 0)); - var processed = 0; - - while (queue.Count > 0) - { - var node = queue.Dequeue(); - processed++; - foreach (var child in children[node]) - { - depth[child] = Math.Max(depth[child], depth[node] + 1); - if (--indegree[child] == 0) - { - queue.Enqueue(child); - } - } - } - - // Any nodes left (a cycle) keep depth 0 — the admin prevents cycles, this is just a guard. - return depth; + Bucket = BucketSize.Year, + MaxSeries = int.MaxValue, + QuantitiesOnly = true, + }; + var result = await _reader.ReadAsync(db, catalog, request, cancellationToken).ConfigureAwait(false); + return new Builder(energyType, catalog, meters, result).Build(); } private static string NodeId(int meterId) => $"m{meterId}"; + /// Turns one analysis result into the graph: what can be drawn, the edges and their values, the remainders. + private sealed class Builder + { + private readonly EnergyType _type; + private readonly AnalysisCatalog _catalog; + private readonly List _meters; + private readonly AnalysisResult _result; + private readonly Dictionary _series; + private readonly Dictionary _drawable = []; + private readonly string _unit; + + public Builder(EnergyType type, AnalysisCatalog catalog, List meters, AnalysisResult result) + { + _type = type; + _catalog = catalog; + _meters = meters; + _result = result; + _series = result.Series.Where(s => s.MeterId is not null).ToDictionary(s => s.MeterId!.Value); + _unit = DrawingUnit(); + } + + public FlowGraph Build() + { + var drawn = _meters.Where(m => IsDrawable(m.Id)).Select(m => m.Id).ToHashSet(); + var sums = drawn.Where(id => _catalog.Meters[id].IsVirtual).ToHashSet(); + + // Structure. Links between drawable meters are topology — except into a virtual sum, whose incoming edges + // are its calculation dependencies. + var parents = drawn.ToDictionary(id => id, _ => new List()); + var children = drawn.ToDictionary(id => id, _ => new List()); + foreach (var link in _catalog.Links.OrderBy(l => l.Id)) + { + var (from, to) = (link.FromMeterId, link.ToMeterId); + if (from != to && drawn.Contains(from) && drawn.Contains(to) && !sums.Contains(to) && !parents[to].Contains(from)) + { + parents[to].Add(from); + children[from].Add(to); + } + } + + var sources = sums.ToDictionary(id => id, id => _catalog.Meters[id].Formula!.MeterIds); + var edges = parents.SelectMany(p => p.Value.Select(parent => (From: parent, To: p.Key))) + .Concat(sources.SelectMany(s => s.Value.Select(source => (From: source, To: s.Key)))) + .ToList(); + + var links = new List(); + var outgoing = drawn.ToDictionary(id => id, _ => 0d); + var estimatedFrom = new HashSet(); + + // A sum's dependencies, each at what it put into the sum: the edges add up to the sum's value. + foreach (var sum in sums.Order()) + { + var contributions = _series[sum].Contributions; + foreach (var source in sources[sum]) + { + if (contributions.FirstOrDefault(c => c.MeterId == source)?.UsedTotal is { } used && used > Epsilon) + { + links.Add(new FlowLink(NodeId(source), NodeId(sum), used) { IsCalculated = true }); + } + } + } + + // Topology: a sub-meter's total flows in from its upstream meter(s), split in proportion to their own values + // when there are several (equally, when those are all zero), then capped at what each upstream meter measured. + var raw = new List<(int Parent, int Child, double Value, bool Split)>(); + foreach (var (child, ups) in parents.Where(p => p.Value.Count > 0).OrderBy(p => p.Key)) + { + if (Value(child) is not { } childValue) + { + continue; + } + + var parentTotal = ups.Sum(Drawn); + foreach (var parent in ups) + { + var share = ups.Count == 1 ? 1d + : parentTotal > Epsilon ? Drawn(parent) / parentTotal + : 1d / ups.Count; + raw.Add((parent, child, Math.Max(0, childValue) * share, ups.Count > 1)); + } + } + + foreach (var group in raw.GroupBy(r => r.Parent)) + { + var capacity = Drawn(group.Key); + var wanted = group.Sum(r => r.Value); + var capped = wanted > capacity + 1e-9; + var factor = capped ? (wanted > 0 ? capacity / wanted : 0) : 1d; + foreach (var (parent, child, value, split) in group) + { + var linkValue = value * factor; + if (linkValue <= Epsilon) + { + continue; + } + + links.Add(new FlowLink(NodeId(parent), NodeId(child), linkValue) { IsEstimated = split || capped, IsCapped = capped }); + outgoing[parent] += linkValue; + if (split || capped) + { + estimatedFrom.Add(parent); + } + } + } + + var inDiagram = drawn.Where(id => Drawn(id) > Epsilon || edges.Exists(e => e.From == id || e.To == id)).ToHashSet(); + var depth = ComputeDepths(inDiagram, edges.Where(e => inDiagram.Contains(e.From) && inDiagram.Contains(e.To)).ToList()); + + var nodes = new List(); + foreach (var meter in _meters.Where(m => inDiagram.Contains(m.Id))) + { + var id = meter.Id; + nodes.Add(new FlowNode(NodeId(id), meter.Name, Drawn(id), depth[id], _type.ColorHex, false, id) + { + Status = _series[id].Total.Status, + IsVirtual = meter.IsVirtual, + }); + + // Unaccounted remainder under a meter with sub-meters → "Other" — only when every sub-meter has a number. + if (children[id].Count == 0 || Value(id) is not { } value || children[id].Exists(c => Value(c) is null)) + { + continue; + } + + var remainder = value - outgoing[id]; + if (remainder > Epsilon) + { + var otherId = $"other{id}"; + var complete = _series[id].Total.Status == BucketStatus.Available + && children[id].TrueForAll(c => _series[c].Total.Status == BucketStatus.Available); + + // Just the parent's name: the "Other (…)" phrasing is added by the UI, which is + // where the reader's language is known. + nodes.Add(new FlowNode(otherId, meter.Name, remainder, depth[id] + 1, OtherColor, true, null) + { + Status = complete ? BucketStatus.Available : BucketStatus.Partial, + }); + links.Add(new FlowLink(NodeId(id), otherId, remainder) { IsEstimated = estimatedFrom.Contains(id) }); + } + } + + var hasParent = edges.Where(e => inDiagram.Contains(e.From)).Select(e => e.To).ToHashSet(); + var total = inDiagram.Where(id => !hasParent.Contains(id)).Sum(Drawn); + + var table = _meters.Select(m => + { + var series = _series[m.Id]; + return new FlowMeter( + m.Id, m.Name, series.Total.Value, series.Total.Status, series.Total.Issue, series.Kind, series.Unit, series.Basis, inDiagram.Contains(m.Id)); + }).ToList(); + + return new FlowGraph(_type.Id, _type.DisplayName, _unit, total, nodes, links) + { + Meters = table, + Problems = _result.Problems, + }; + } + + /// The canonical period total of a meter, signed; null when it has no number. + private double? Value(int meterId) => _series.TryGetValue(meterId, out var series) ? series.Total.Value : null; + + /// The size a meter is drawn with: its total, never below zero, and zero when it has none. + private double Drawn(int meterId) => Math.Max(0, Value(meterId) ?? 0); + + /// + /// The unit the diagram draws in: the energy type's base unit when any of its meters measures in it, otherwise + /// the unit most of its physical meters measure in. + /// + private string DrawingUnit() + { + if (_meters.Exists(m => Units.AreSame(m.Quantity.Unit, _type.BaseUnit))) + { + return _type.BaseUnit; + } + + return _meters.Where(m => !m.IsVirtual) + .GroupBy(m => Units.Normalize(m.Quantity.Unit), StringComparer.OrdinalIgnoreCase) + .OrderByDescending(g => g.Count()) + .ThenBy(g => g.Min(m => m.Id)) + .Select(g => g.Key) + .FirstOrDefault() ?? _type.BaseUnit; + } + + /// + /// True when the meter can be a node: a meter of this type in the diagram's unit that is physical, or a virtual + /// pure sum (not an indicator) over meters that can be nodes themselves. + /// + private bool IsDrawable(int meterId) + { + if (_drawable.TryGetValue(meterId, out var known)) + { + return known; + } + + // Undecided counts as not drawable while it is being decided, so a loop cannot recurse forever. + _drawable[meterId] = false; + var drawable = _catalog.Find(meterId) is { } meter + && meter.EnergyTypeId == _type.Id + && _series.ContainsKey(meterId) + && Units.AreSame(meter.Quantity.Unit, _unit) + && (!meter.IsVirtual + || (meter.Formula is { IsPureSum: true } formula + && meter.Quantity.Kind != QuantityKind.Indicator + && formula.MeterIds.All(IsDrawable))); + _drawable[meterId] = drawable; + return drawable; + } + + /// Longest-path depth from the roots (Kahn topological relaxation); robust to stray cycles. + private static Dictionary ComputeDepths(HashSet ids, List<(int From, int To)> edges) + { + var depth = ids.ToDictionary(id => id, _ => 0); + var indegree = ids.ToDictionary(id => id, _ => 0); + var next = ids.ToDictionary(id => id, _ => new List()); + foreach (var (from, to) in edges) + { + next[from].Add(to); + indegree[to]++; + } + + var queue = new Queue(ids.Where(id => indegree[id] == 0).Order()); + while (queue.Count > 0) + { + var node = queue.Dequeue(); + foreach (var child in next[node]) + { + depth[child] = Math.Max(depth[child], depth[node] + 1); + if (--indegree[child] == 0) + { + queue.Enqueue(child); + } + } + } + + // Any nodes left (a cycle) keep the depth reached so far — the editor prevents cycles, this is just a guard. + return depth; + } + } } diff --git a/src/Infrastructure/Dashboard/MeterDetailModels.cs b/src/Infrastructure/Dashboard/MeterDetailModels.cs index 7833ff5..c7747a9 100644 --- a/src/Infrastructure/Dashboard/MeterDetailModels.cs +++ b/src/Infrastructure/Dashboard/MeterDetailModels.cs @@ -1,75 +1,40 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; namespace MeterVault.Infrastructure.Dashboard; -/// A raw reading row for the meter-detail table. +/// A raw reading row for the meter page's Readings tab (audit truth, raw unit). public sealed record ReadingRow(DateTimeOffset Time, double Value, ReadingQuality Quality, ReadingFlags Flags); -/// A normalized consumption row for the meter-detail table. +/// A normalized consumption row for the meter page's Normalized data tab (normalized unit, D-20). public sealed record ConsumptionDetailRow(DateTimeOffset Time, double Amount, ConsumptionKind Kind, ReadingQuality Quality); -/// One calendar month of a meter's normalized history, bucketed in the instance timezone. -public sealed record MeterMonthPoint(DateOnly Month, double Amount, double Cost); - -/// -/// A meter framed the way it is actually read: what it used this period, how that compares with the -/// last one, and where the year is heading. Amounts are generation for a generation counter and -/// consumption otherwise, so says which — as the enum, not a word, because the -/// wording belongs to whichever language the reader picked. -/// -/// -/// Month- and year-to-date are compared against a projection of the current period rather -/// than its raw running total: three days into a month, "12 kWh vs 340 kWh last month" reads as a -/// collapse in usage when nothing has changed. Projections are flagged so the UI can mark them. -/// -public sealed record MeterPeriodView( - ConsumptionKind Kind, - string Unit, - string Currency, - double MonthToDate, - double MonthProjected, - double LastMonth, - double YearToDate, - double YearProjected, - double LastYear, - double YearToDateCost, - double YearProjectedCost, - double LastYearCost, - bool MonthIsPartial, - IReadOnlyList Last12Months) -{ - /// Projected month against last month, as a fraction (+0.12 = 12% more). Null if no basis. - public double? MonthChange => Ratio(MonthProjected, LastMonth); - - /// Projected year against last year, as a fraction. Null if no basis. - public double? YearChange => Ratio(YearProjected, LastYear); - - public bool HasHistory => Last12Months.Count > 0; - - /// - /// Percentage change is only meaningful against a positive baseline. Dividing by a negative one - /// inverts the sign — a net-export meter going from −100 to −150 would report "+50% more used" - /// when it exported half as much again — so those report no basis rather than a confident lie. - /// - private static double? Ratio(double current, double previous) => - previous <= 1e-9 ? null : (current - previous) / previous; -} - -/// A meter lifecycle/correction event row. marks one only its batch can remove. +/// A meter lifecycle event row. marks one only its batch can remove. public sealed record EventRow(int Id, DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes, int? ImportBatchId); -/// A tariff applicable to the meter (own / energy-type / global scope), for the timeline. -public sealed record TariffRow(TariffScope Scope, int? ScopeId, TariffComponent Component, double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo); +/// A tariff applicable to the meter (own, energy-type or global scope). +public sealed record TariffRow(int Id, TariffScope Scope, int? ScopeId, TariffComponent Component, double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo); + +/// A register value at an instant. +public sealed record RegisterPoint(DateTimeOffset Time, double Value); /// -/// The meter-detail read model (SDD §8.6): identity, register span, totals, recent raw readings -/// and normalized consumption (measured-vs-estimated markers via quality), the applicable tariff -/// timeline, and lifecycle events (swaps/deliveries/corrections). Source management loads the -/// source entities directly (they are editable), so it is not part of this read model. +/// The meter page's identity read (SDD §8.6, brief §7.2): who the meter is, what it measures and how it is fed — the +/// header, the tab set and the per-mode entry actions. Figures are not part of it: they come from the shared analysis +/// reader for the selected period, and the record tabs page through their rows on their own (D-50). /// +/// What the meter's normalized amounts measure (D-20). +/// The unit of its normalized amounts (D-20); raw readings keep . +/// True when any raw reading exists. +/// True when any event exists. +/// The earliest raw reading (by stamp). +/// The latest raw reading (by stamp). public sealed record MeterDetailView( int Id, string Name, + short EnergyTypeId, string EnergyType, MeterMode Mode, string Unit, @@ -79,18 +44,118 @@ public sealed record MeterDetailView( string? Model, double InitialBaseline, bool IsActive, - int ReadingCount, - int ConsumptionCount, - DateTimeOffset? FirstReadingTime, - DateTimeOffset? LastReadingTime, - double? FirstReadingValue, - double? LastReadingValue, - double TotalConsumption, - double TotalGeneration, - IReadOnlyList RecentReadings, - IReadOnlyList RecentConsumption, - IReadOnlyList Events, - IReadOnlyList Tariffs, + DateOnly? InstalledAt, + DateOnly? RetiredAt, + bool HasTank, int SourceCount, - short EnergyTypeId, - bool HasTank); + bool HasReadings, + bool HasEvents, + RegisterPoint? FirstReading, + RegisterPoint? LastReading, + QuantityKind Kind, + string NormalizedUnit) +{ + public bool IsVirtual => Mode == MeterMode.Virtual; +} + +/// +/// A date filter of a record tab (D-50): a half-open UTC range [From, To), either end open. The page fills it from +/// its period keys, so a drill-down into a bucket lands on exactly the bucket's records. +/// +public sealed record RecordRange(DateTimeOffset? From, DateTimeOffset? To) +{ + public static RecordRange All { get; } = new(null, null); + + public bool IsBounded => From is not null || To is not null; +} + +/// +/// Where a page of a record tab continues (keyset, D-50): the rows strictly older than — or at +/// with a smaller (a consumption row's kind, an event's id). Stable under +/// inserts, unlike an offset. +/// +public sealed record RecordCursor(DateTimeOffset Time, int Tiebreak = 0); + +/// +/// One page of a record tab, newest first: its rows, where the next (older) page starts — null on the last page — and +/// how many rows the filter holds, counted up to . +/// +public sealed record RecordPage(IReadOnlyList Rows, RecordCursor? Next, int Total, bool TotalIsCapped) +{ + public static RecordPage Empty { get; } = new([], null, 0, false); +} + +/// +/// Context for the analysis chart (brief §7.2): the lifecycle events and tariff changes inside the selected range, +/// bounded, so the reader can relate a jump to a swap, a delivery or a new price. +/// +/// Events in the range, newest first, at most the requested number. +/// True when the range holds more events than listed. +/// Applicable tariffs whose validity starts inside the range, oldest first. +public sealed record MeterMarkers(IReadOnlyList Events, bool MoreEvents, IReadOnlyList TariffChanges) +{ + public static MeterMarkers None { get; } = new([], false, []); + + public bool IsEmpty => Events.Count == 0 && TariffChanges.Count == 0; +} + +/// A reading stored at exactly the entered instant — what saving would replace. +public sealed record ExistingReading(DateTimeOffset Time, double Value, ReadingQuality Quality, ReadingFlags Flags) +{ + /// True when it is the start value a swap or reset wrote for the new register. + public bool IsRegisterStart => (Flags & (ReadingFlags.MeterSwap | ReadingFlags.CounterReset)) != 0; +} + +/// +/// What the manual-reading dialog judges an entry against (D-50), read for the entered instant on its own — never from +/// a page of rows the tab happens to show: the latest reading (the prefill and its caption), the reading before the +/// instant on the normalizer's timeline, whether a swap or reset explains a lower value, and the reading the save would +/// replace. The same neighbours the ingestion guard uses, so the dialog's warning is the save's verdict. +/// +/// The instant the context was read for (UTC). +/// True when the register only counts up, so a lower value is refused without a swap or reset. +/// The meter's latest reading by stamp. +/// The reading before in the order the normalizer walks them. +/// A swap or reset lies in (Previous, At]. +/// The reading stamped exactly at , which a save replaces. +public sealed record ReadingEntryContext( + int MeterId, + DateTimeOffset At, + string Unit, + double InitialBaseline, + bool Monotonic, + RegisterPoint? Latest, + RegisterPoint? Previous, + bool BoundaryExplainsDecrease, + ExistingReading? AtTime); + +/// A meter a calculation reads (its formula names it), with what it measures. +public sealed record CalculationSource(int MeterId, string Name, MeterMode Mode, QuantityKind Kind, string Unit, bool Exists) +{ + public bool IsVirtual => Mode == MeterMode.Virtual; +} + +/// +/// A virtual meter's calculation as analysis reads it (D-25 – D-28, D-31): the definition's status, the expression (for +/// a legacy meter the sum its links imply), the effective result kind, unit and cost rule, the meters it names and the +/// physical meters it finally reads, the validation findings (with the meters involved), and every meter's name — so +/// the Calculation tab can put a name beside each m<id> token without another query. +/// +public sealed record MeterCalculationView( + int MeterId, + VirtualMeterStatus Status, + string? Expression, + QuantityKind Kind, + string Unit, + VirtualCostRule CostRule, + VirtualCostRule? DeclaredCostRule, + IReadOnlyList Sources, + IReadOnlyList PhysicalLeaves, + IReadOnlyList Problems, + VirtualProblem? CostRuleProblem, + LegacyDerivation? Legacy, + IReadOnlyDictionary Names) +{ + /// The name of a meter the calculation mentions, or null for an id that names no meter. + public string? NameOf(int meterId) => Names.TryGetValue(meterId, out var name) ? name : null; +} diff --git a/src/Infrastructure/Dashboard/MeterDetailService.cs b/src/Infrastructure/Dashboard/MeterDetailService.cs index e279218..1b69d7d 100644 --- a/src/Infrastructure/Dashboard/MeterDetailService.cs +++ b/src/Infrastructure/Dashboard/MeterDetailService.cs @@ -1,85 +1,405 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Ingestion; +using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace MeterVault.Infrastructure.Dashboard; /// -/// Read model for the meter-detail view (SDD §8.6). Bounds the raw-reading and consumption pulls -/// (this is the one place the UI touches raw rows) and gathers source status, the applicable tariff -/// timeline and lifecycle events. DbContext factory keeps it Blazor-circuit safe. +/// Read model for the meter page (SDD §8.6, brief §7.2): the meter's identity, its raw readings, normalized rows and +/// events one bounded page at a time (D-50), the tariffs that apply to it, the context the manual-entry dialog judges an +/// entry against, and a virtual meter's calculation. The page's figures are not here — they come from the shared +/// analysis reader and cost engine for the selected period, like every other analysis page. /// -public sealed class MeterDetailService(IDbContextFactory contextFactory) +/// +/// +/// Every record query is bounded: a page is rows, keyset-ordered newest first on the table's +/// key (a reading's instant; a consumption row's instant and kind; an event's instant and id), so paging is stable while +/// rows arrive and never scans what it skips. Counts stop at . This is the only place the UI reads +/// raw rows (D-57: raw readings are kept, and are the audit record, not the analytical history). +/// +/// A context per call (DbContext factory), so it is safe on a Blazor circuit. +/// +public sealed class MeterDetailService { - private const int MaxRows = 200; + /// Rows per page of a record tab (D-50). + public const int PageSize = 100; - private readonly IDbContextFactory _contextFactory = contextFactory; + /// Counts stop here: "10,000+" says enough, and counting further is only a slower query. + public const int CountCap = 10_000; + private readonly IDbContextFactory _contextFactory; + private readonly AnalysisReader _reader; + private readonly TimeZoneInfo _zone; + + /// The database. + /// The instance options; the zone orders readings as the normalizer does (UTC without options). + /// The analysis reader whose catalog validates a calculation; one on the same options by default. + public MeterDetailService( + IDbContextFactory contextFactory, + IOptions? options = null, + AnalysisReader? reader = null) + { + ArgumentNullException.ThrowIfNull(contextFactory); + + _contextFactory = contextFactory; + _reader = reader ?? new AnalysisReader(contextFactory, options); + _zone = InstanceTimeZone.Resolve(options?.Value.TimeZone); + } + + /// The meter's identity and how it is fed, or null when it does not exist. public async Task GetAsync(int meterId, CancellationToken cancellationToken = default) { await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); var meter = await db.Meters.AsNoTracking() .Include(m => m.EnergyType) - .Include(m => m.Sources) .FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); if (meter is null) { return null; } - var readingCount = await db.Readings.AsNoTracking().CountAsync(r => r.MeterId == meterId, cancellationToken).ConfigureAwait(false); - var consumptionCount = await db.Consumption.AsNoTracking().CountAsync(c => c.MeterId == meterId, cancellationToken).ConfigureAwait(false); - - var first = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId) - .OrderBy(r => r.Time).Select(r => new { r.Time, r.Value }) + var readings = db.Readings.AsNoTracking().Where(r => r.MeterId == meterId); + var first = await readings.OrderBy(r => r.Time).Select(r => new RegisterPoint(r.Time, r.Value)) .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); - var last = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId) - .OrderByDescending(r => r.Time).Select(r => new { r.Time, r.Value }) + var last = await readings.OrderByDescending(r => r.Time).Select(r => new RegisterPoint(r.Time, r.Value)) .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + var hasEvents = await db.MeterEvents.AsNoTracking().AnyAsync(e => e.MeterId == meterId, cancellationToken).ConfigureAwait(false); + var sourceCount = await db.MeterSources.AsNoTracking().CountAsync(s => s.MeterId == meterId, cancellationToken).ConfigureAwait(false); + var tank = meter.Mode == MeterMode.Virtual + ? null + : await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meterId, cancellationToken).ConfigureAwait(false); - var totalConsumption = await db.Consumption.AsNoTracking() - .Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Consumption) - .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0; - var totalGeneration = await db.Consumption.AsNoTracking() - .Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Generation) - .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0; - - var recentReadings = await db.Readings.AsNoTracking() - .Where(r => r.MeterId == meterId) - .OrderByDescending(r => r.Time).Take(MaxRows) - .Select(r => new ReadingRow(r.Time, r.Value, r.Quality, r.Flags)) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - var recentConsumption = await db.Consumption.AsNoTracking() - .Where(c => c.MeterId == meterId) - .OrderByDescending(c => c.Time).Take(MaxRows) - .Select(c => new ConsumptionDetailRow(c.Time, c.Amount, c.Kind, c.Quality)) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - var events = await db.MeterEvents.AsNoTracking() - .Where(e => e.MeterId == meterId) - .OrderByDescending(e => e.Time).ThenByDescending(e => e.Id) - .Select(e => new EventRow(e.Id, e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes, e.ImportBatchId)) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - var energyTypeId = meter.EnergyTypeId; - var tariffs = await db.Tariffs.AsNoTracking() - .Where(t => t.ScopeType == TariffScope.Global - || (t.ScopeType == TariffScope.EnergyType && t.ScopeId == energyTypeId) - || (t.ScopeType == TariffScope.Meter && t.ScopeId == meterId)) - .OrderBy(t => t.Component).ThenBy(t => t.ValidFrom) - .Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo)) - .ToListAsync(cancellationToken).ConfigureAwait(false); - - var hasTank = await db.Tanks.AsNoTracking().AnyAsync(t => t.MeterId == meterId, cancellationToken).ConfigureAwait(false); + // What the normalized amounts measure (D-20). A virtual meter's is its declared result; the catalog validates it + // (GetCalculationAsync) — here the declaration is enough to label it. + var declared = meter.Mode == MeterMode.Virtual ? VirtualDefinitionJson.Read(meter.Meta).Definition?.DeclaredResult : null; + var quantity = NormalizedQuantity.Of(meter, tank, declared); return new MeterDetailView( - meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit, - meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive, - readingCount, consumptionCount, - first?.Time, last?.Time, first?.Value, last?.Value, - totalConsumption, totalGeneration, - recentReadings, recentConsumption, events, tariffs, meter.Sources.Count, meter.EnergyTypeId, hasTank); + meter.Id, + meter.Name, + meter.EnergyTypeId, + meter.EnergyType?.DisplayName ?? string.Empty, + meter.Mode, + meter.Unit, + meter.Location, + meter.SerialNumber, + meter.Manufacturer, + meter.Model, + meter.InitialBaseline, + meter.IsActive, + meter.InstalledAt, + meter.RetiredAt, + tank is not null, + sourceCount, + first is not null, + hasEvents, + first, + last, + quantity.Kind, + quantity.Unit); + } + + /// One page of the meter's raw readings in , newest first (D-50). + /// The meter. + /// The date filter. + /// Where the page starts: the previous page's ; null for the newest rows. + /// Cancels the queries. + public async Task> GetReadingsAsync( + int meterId, RecordRange range, RecordCursor? after = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(range); + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var rows = db.Readings.AsNoTracking().Where(r => r.MeterId == meterId); + if (range.From is { } from) + { + rows = rows.Where(r => r.Time >= from); + } + + if (range.To is { } to) + { + rows = rows.Where(r => r.Time < to); + } + + var total = await rows.Take(CountCap + 1).CountAsync(cancellationToken).ConfigureAwait(false); + var page = rows; + if (after is { } cursor) + { + // A reading's key is (meter, instant): the instant alone orders it. + page = page.Where(r => r.Time < cursor.Time); + } + + var list = await page.OrderByDescending(r => r.Time).Take(PageSize + 1) + .Select(r => new ReadingRow(r.Time, r.Value, r.Quality, r.Flags)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + return Page(list, total, r => new RecordCursor(r.Time)); + } + + /// One page of the meter's normalized rows in , newest first (D-50). + public async Task> GetConsumptionAsync( + int meterId, RecordRange range, RecordCursor? after = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(range); + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var rows = db.Consumption.AsNoTracking().Where(c => c.MeterId == meterId); + if (range.From is { } from) + { + rows = rows.Where(c => c.Time >= from); + } + + if (range.To is { } to) + { + rows = rows.Where(c => c.Time < to); + } + + var total = await rows.Take(CountCap + 1).CountAsync(cancellationToken).ConfigureAwait(false); + var page = rows; + if (after is { } cursor) + { + // Key (meter, instant, kind): at one instant a meter can book consumption and generation. + var kind = (ConsumptionKind)cursor.Tiebreak; + page = page.Where(c => c.Time < cursor.Time || (c.Time == cursor.Time && c.Kind < kind)); + } + + var list = await page.OrderByDescending(c => c.Time).ThenByDescending(c => c.Kind).Take(PageSize + 1) + .Select(c => new ConsumptionDetailRow(c.Time, c.Amount, c.Kind, c.Quality)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + return Page(list, total, c => new RecordCursor(c.Time, (int)c.Kind)); + } + + /// One page of the meter's events in , newest first (D-50). + public async Task> GetEventsAsync( + int meterId, RecordRange range, RecordCursor? after = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(range); + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var rows = EventsIn(db, meterId, range); + var total = await rows.Take(CountCap + 1).CountAsync(cancellationToken).ConfigureAwait(false); + var page = rows; + if (after is { } cursor) + { + var id = cursor.Tiebreak; + page = page.Where(e => e.Time < cursor.Time || (e.Time == cursor.Time && e.Id < id)); + } + + var list = await page.OrderByDescending(e => e.Time).ThenByDescending(e => e.Id).Take(PageSize + 1) + .Select(e => new EventRow(e.Id, e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes, e.ImportBatchId)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + return Page(list, total, e => new RecordCursor(e.Time, e.Id)); + } + + /// + /// The tariffs that can price the meter — its own, its energy type's and the global ones — by component, then + /// start date. All of them, not only those of a period: a price history is short. + /// + public async Task> GetTariffsAsync(int meterId, CancellationToken cancellationToken = default) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var energyTypeId = await db.Meters.AsNoTracking().Where(m => m.Id == meterId).Select(m => (short?)m.EnergyTypeId) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + if (energyTypeId is not { } typeId) + { + return []; + } + + return await TariffsOf(db, meterId, typeId) + .OrderBy(t => t.Component).ThenBy(t => t.ScopeType).ThenBy(t => t.ValidFrom) + .Select(t => new TariffRow(t.Id, t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// The events and tariff changes inside (brief §7.2 contextual markers), at most + /// events. + /// + /// The meter. + /// The analysis range. + /// The range's first local day: tariffs starting on or after it count. + /// The range's last local day. + /// The most events listed. + /// Cancels the queries. + public async Task GetMarkersAsync( + int meterId, RecordRange range, DateOnly firstDay, DateOnly lastDay, int maxEvents = 12, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(range); + ArgumentOutOfRangeException.ThrowIfNegative(maxEvents); + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var energyTypeId = await db.Meters.AsNoTracking().Where(m => m.Id == meterId).Select(m => (short?)m.EnergyTypeId) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + if (energyTypeId is not { } typeId) + { + return MeterMarkers.None; + } + + var events = await EventsIn(db, meterId, range) + .OrderByDescending(e => e.Time).ThenByDescending(e => e.Id).Take(maxEvents + 1) + .Select(e => new EventRow(e.Id, e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes, e.ImportBatchId)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + var tariffs = await TariffsOf(db, meterId, typeId) + .Where(t => t.ValidFrom >= firstDay && t.ValidFrom <= lastDay) + .OrderBy(t => t.ValidFrom).ThenBy(t => t.Component) + .Take(maxEvents) + .Select(t => new TariffRow(t.Id, t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var more = events.Count > maxEvents; + return new MeterMarkers(more ? events[..maxEvents] : events, more, tariffs); + } + + /// + /// What the manual-entry dialog judges a reading at against (D-50): read on its own, with the + /// neighbours the ingestion guard uses, so the dialog's warning never depends on a page of rows. Null when the meter + /// is gone. + /// + public async Task GetReadingEntryContextAsync( + int meterId, DateTimeOffset at, CancellationToken cancellationToken = default) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var meter = await db.Meters.AsNoTracking() + .Where(m => m.Id == meterId) + .Select(m => new { m.Mode, m.Unit, m.InitialBaseline }) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + if (meter is null) + { + return null; + } + + var utc = at.ToUniversalTime(); + var readings = db.Readings.AsNoTracking().Where(r => r.MeterId == meterId); + var latest = await readings.OrderByDescending(r => r.Time).Select(r => new RegisterPoint(r.Time, r.Value)) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + var atTime = await readings.Where(r => r.Time == utc) + .Select(r => new ExistingReading(r.Time, r.Value, r.Quality, r.Flags)) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + // The neighbours on the normalizer's timeline — a month row stamped on the 1st describes the end of its month — + // and the swaps or resets between them, exactly as IngestionService's decrease guard reads them. + var neighbours = await RegisterNeighbours.FindAsync(db, meterId, utc, _zone, cancellationToken).ConfigureAwait(false); + var previous = neighbours.Previous is { } p ? new RegisterPoint(p.Reading.Time, p.Reading.Value) : null; + + return new ReadingEntryContext( + meterId, + utc, + meter.Unit, + meter.InitialBaseline, + MeterEventRules.IsMonotonic(meter.Mode), + latest, + previous, + neighbours.BoundaryAfterPreviousUpTo(utc), + atTime); + } + + /// + /// The names of (user data, never translated) — for a figure or an attention item that + /// speaks of a meter the page's own result does not name, such as the other end of a dependency loop. Unknown ids are + /// left out. + /// + public async Task> GetMeterNamesAsync( + IEnumerable meterIds, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(meterIds); + + var ids = meterIds.Distinct().ToArray(); + if (ids.Length == 0) + { + return new Dictionary(); + } + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + return await db.Meters.AsNoTracking() + .Where(m => ids.Contains(m.Id)) + .ToDictionaryAsync(m => m.Id, m => m.Name, cancellationToken).ConfigureAwait(false); + } + + /// + /// A virtual meter's calculation as the analysis reader sees it (D-26 – D-28): validated against the current + /// catalog, with the sources' names, kinds and units. Null for a meter that does not exist or is not virtual. + /// + public async Task GetCalculationAsync(int meterId, CancellationToken cancellationToken = default) + { + var catalog = await _reader.LoadCatalogAsync(cancellationToken).ConfigureAwait(false); + return CalculationOf(catalog, meterId); + } + + /// The calculation of in a loaded catalog; null unless it is a virtual meter. + public static MeterCalculationView? CalculationOf(AnalysisCatalog catalog, int meterId) + { + ArgumentNullException.ThrowIfNull(catalog); + + if (catalog.Find(meterId) is not { IsVirtual: true } meter) + { + return null; + } + + var validation = meter.Validation; + var status = meter.VirtualStatus ?? VirtualMeterStatus.NeedsConfiguration; + var definition = meter.Definition; + var referenced = definition?.ReferencedMeterIds ?? (IReadOnlyList)(meter.Legacy?.MeterIds ?? []); + + var names = new Dictionary(); + foreach (var entry in catalog.Meters.Values) + { + names[entry.Id] = entry.Name; + } + + CalculationSource Source(int id) => catalog.Find(id) is { } source + ? new CalculationSource(id, source.Name, source.Meter.Mode, source.Quantity.Kind, source.Quantity.Unit, Exists: true) + : new CalculationSource(id, string.Empty, MeterMode.Virtual, QuantityKind.Consumption, string.Empty, Exists: false); + + var problems = validation?.Problems ?? []; + return new MeterCalculationView( + meter.Id, + status, + definition?.Expression, + validation?.Kind ?? meter.Quantity.Kind, + validation?.Unit ?? meter.Quantity.Unit, + meter.CostRule, + meter.StoredDefinition?.Definition?.CostRule, + [.. referenced.Distinct().Order().Select(Source)], + [.. catalog.PhysicalLeaves(referenced).Select(Source)], + problems, + validation?.CostRuleProblem, + meter.Legacy, + names); + } + + private static IQueryable EventsIn(MeterVaultDbContext db, int meterId, RecordRange range) + { + var rows = db.MeterEvents.AsNoTracking().Where(e => e.MeterId == meterId); + if (range.From is { } from) + { + rows = rows.Where(e => e.Time >= from); + } + + if (range.To is { } to) + { + rows = rows.Where(e => e.Time < to); + } + + return rows; + } + + private static IQueryable TariffsOf(MeterVaultDbContext db, int meterId, short energyTypeId) => + db.Tariffs.AsNoTracking().Where(t => t.ScopeType == TariffScope.Global + || (t.ScopeType == TariffScope.EnergyType && t.ScopeId == energyTypeId) + || (t.ScopeType == TariffScope.Meter && t.ScopeId == meterId)); + + /// A page from + 1 fetched rows: the extra row only says there is a next page. + private static RecordPage Page(List rows, int total, Func cursorOf) + { + var more = rows.Count > PageSize; + var page = more ? rows[..PageSize] : rows; + return new RecordPage(page, more ? cursorOf(page[^1]) : null, Math.Min(total, CountCap), total > CountCap); } } diff --git a/src/Infrastructure/Dashboard/MeterLinkService.cs b/src/Infrastructure/Dashboard/MeterLinkService.cs new file mode 100644 index 0000000..32a4d07 --- /dev/null +++ b/src/Infrastructure/Dashboard/MeterLinkService.cs @@ -0,0 +1,367 @@ +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Dashboard; + +/// Why a connection between two meters cannot be added or removed; a code the UI words. +public enum MeterLinkRefusal +{ + None, + + /// A meter cannot flow into itself. + SameMeter, + + /// One of the meters does not exist (any more). + UnknownMeter, + + /// The meters belong to different energy types: a flow, a breakdown and a bill never cross types. + OtherEnergyType, + + /// The two meters are already connected in this direction. + AlreadyLinked, + + /// + /// The destination already flows, directly or through other meters, into the source: the connection would close a + /// loop. is that existing path, from the destination to the source. + /// + WouldCreateCycle, + + /// + /// The destination is a virtual meter without a stored calculation, which analysis evaluates as the sum of its + /// incoming connections until its calculation is confirmed (D-28): changing them would silently change what it + /// calculates. Its calculation is set in the meter editor instead. + /// + CalculatedFromLinks, + + /// The connection to remove does not exist (any more). + NotFound, +} + +/// The verdict on adding or removing a connection. +/// Why it is refused; when it is allowed. +/// For , the meters of the existing path that would become a loop. +public sealed record MeterLinkCheck(MeterLinkRefusal Refusal, IReadOnlyList Path) +{ + public static MeterLinkCheck Allowed { get; } = new(MeterLinkRefusal.None, []); + + public bool IsAllowed => Refusal == MeterLinkRefusal.None; + + public static MeterLinkCheck Refused(MeterLinkRefusal refusal) => new(refusal, []); +} + +/// A meter as the connection rules see it. +/// The meter. +/// Its name (user data). +/// Its energy type. +/// Its measurement mode. +/// False for a retired meter (it keeps its connections for its history). +/// +/// A virtual meter without a stored calculation (): its incoming +/// connections are what it calculates, so they are not edited here. +/// +/// For a virtual meter with a stored calculation, the meters its formula names. +public sealed record MeterLinkMeter( + int Id, + string Name, + int EnergyTypeId, + MeterMode Mode, + bool IsActive, + bool CalculatedFromLinks, + IReadOnlyList ReferencedMeterIds) +{ + public bool IsVirtual => Mode == MeterMode.Virtual; +} + +/// One connection: energy flows from into . +/// The stored link. +/// The upstream meter (the one measuring the whole, or supplying it). +/// The downstream meter (a part of it, or what it supplies). +public sealed record MeterLinkEntry(int LinkId, int FromMeterId, int ToMeterId); + +/// +/// The connections of one energy type: its meters (and the few meters of other types a stray link touches), every link +/// touching them, and the rules for adding and removing one (). +/// +/// The energy type. +/// The type's meters, by name. +/// Every link with at least one end in the type, ordered by upstream then downstream name. +/// Meters of other types a link of touches, by id (for their names). +/// Every link of the instance ((from, to)), which the loop check walks. +public sealed record MeterLinkTopology( + int EnergyTypeId, + IReadOnlyList Meters, + IReadOnlyList Links, + IReadOnlyDictionary Others, + IReadOnlyList<(int From, int To)> AllLinks) +{ + /// A meter of the type, or of another type a link touches; null when unknown. + public MeterLinkMeter? Find(int meterId) => + Meters.FirstOrDefault(m => m.Id == meterId) ?? Others.GetValueOrDefault(meterId); + + /// Whether may be added now. + public MeterLinkCheck CheckAdd(int fromMeterId, int toMeterId) => + MeterLinkRules.CheckAdd(Find(fromMeterId), Find(toMeterId), AllLinks, fromMeterId, toMeterId); + + /// Whether the link may be removed now. + public MeterLinkCheck CheckRemove(MeterLinkEntry link) + { + ArgumentNullException.ThrowIfNull(link); + + return MeterLinkRules.CheckRemove(Find(link.ToMeterId)); + } + + /// + /// True when the link runs into a virtual meter whose stored formula names its upstream meter: it mirrors a + /// calculation input for the diagram, but the formula — not the link — decides the calculation (D-25). + /// + public bool MirrorsCalculation(MeterLinkEntry link) + { + ArgumentNullException.ThrowIfNull(link); + + return Find(link.ToMeterId) is { IsVirtual: true, CalculatedFromLinks: false } target + && target.ReferencedMeterIds.Contains(link.FromMeterId); + } +} + +/// +/// The rules for editing the flow topology (), shared by the energy type page's "Manage +/// connections" dialog and its tests: no meter into itself, no link across energy types, no duplicate, no loop — the +/// same guard the meter editor's upstream picker applies (it only offers meters that are not below the meter) — and no +/// change to the incoming links of a virtual meter that is still calculated from them (D-28). A link is topology only: +/// it never writes or changes a virtual meter's stored calculation (D-25). +/// +public static class MeterLinkRules +{ + /// The verdict on adding . + /// The upstream meter, or null when unknown. + /// The downstream meter, or null when unknown. + /// Every existing link of the instance. + /// The upstream meter's id. + /// The downstream meter's id. + public static MeterLinkCheck CheckAdd( + MeterLinkMeter? from, MeterLinkMeter? to, IReadOnlyCollection<(int From, int To)> links, int fromMeterId, int toMeterId) + { + ArgumentNullException.ThrowIfNull(links); + + if (fromMeterId == toMeterId) + { + return MeterLinkCheck.Refused(MeterLinkRefusal.SameMeter); + } + + if (from is null || to is null) + { + return MeterLinkCheck.Refused(MeterLinkRefusal.UnknownMeter); + } + + if (from.EnergyTypeId != to.EnergyTypeId) + { + return MeterLinkCheck.Refused(MeterLinkRefusal.OtherEnergyType); + } + + if (links.Contains((fromMeterId, toMeterId))) + { + return MeterLinkCheck.Refused(MeterLinkRefusal.AlreadyLinked); + } + + if (to.CalculatedFromLinks) + { + return MeterLinkCheck.Refused(MeterLinkRefusal.CalculatedFromLinks); + } + + return PathBetween(links, toMeterId, fromMeterId) is { } loop + ? new MeterLinkCheck(MeterLinkRefusal.WouldCreateCycle, loop) + : MeterLinkCheck.Allowed; + } + + /// The verdict on removing a link into . + public static MeterLinkCheck CheckRemove(MeterLinkMeter? to) => + to is { CalculatedFromLinks: true } ? MeterLinkCheck.Refused(MeterLinkRefusal.CalculatedFromLinks) : MeterLinkCheck.Allowed; + + /// + /// The shortest existing path of links from to (both included), + /// or null when there is none — the loop a link would close. + /// + public static IReadOnlyList? PathBetween(IReadOnlyCollection<(int From, int To)> links, int start, int target) + { + ArgumentNullException.ThrowIfNull(links); + + var next = links.GroupBy(l => l.From).ToDictionary(g => g.Key, g => g.Select(l => l.To).Order().ToList()); + var previous = new Dictionary(); + var queue = new Queue(); + queue.Enqueue(start); + var seen = new HashSet { start }; + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (current == target) + { + var path = new List { current }; + while (previous.TryGetValue(path[^1], out var before)) + { + path.Add(before); + } + + path.Reverse(); + return path; + } + + foreach (var child in next.GetValueOrDefault(current) ?? []) + { + if (seen.Add(child)) + { + previous[child] = current; + queue.Enqueue(child); + } + } + } + + return null; + } + + /// + /// The connection facts of a meter entity: a virtual meter without a stored calculation is calculated from its + /// links; one with a stored calculation names its formula's meters (a malformed one names nothing and is never + /// derived from links). + /// + public static MeterLinkMeter Describe(Meter meter) + { + ArgumentNullException.ThrowIfNull(meter); + + if (meter.Mode != MeterMode.Virtual) + { + return new MeterLinkMeter(meter.Id, meter.Name, meter.EnergyTypeId, meter.Mode, meter.IsActive, false, []); + } + + var read = VirtualDefinitionJson.Read(meter.Meta); + return new MeterLinkMeter( + meter.Id, + meter.Name, + meter.EnergyTypeId, + meter.Mode, + meter.IsActive, + read.Status == VirtualDefinitionReadStatus.Absent, + read.Status == VirtualDefinitionReadStatus.Present ? read.Definition?.ReferencedMeterIds ?? [] : []); + } +} + +/// +/// Reads and edits an energy type's flow topology () under . Every +/// change is checked again inside its own transaction, against the links as stored, with the link table locked +/// against concurrent writers — two people cannot close a loop between them. Only meter_link rows are written: +/// no meter, no stored calculation, no reading. Analysis reads topology on every request, so the next read sees the +/// change; nothing needs recomputing. +/// +public sealed class MeterLinkService(IDbContextFactory contextFactory) +{ + private readonly IDbContextFactory _contextFactory = contextFactory; + + /// The connections of . + public async Task GetAsync(int energyTypeId, CancellationToken cancellationToken = default) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + return await LoadAsync(db, energyTypeId, cancellationToken).ConfigureAwait(false); + } + + /// Adds when the rules allow it. + /// The verdict; the link is stored only when it is allowed. + public async Task AddAsync(int fromMeterId, int toMeterId, CancellationToken cancellationToken = default) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using var tx = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await LockAsync(db, cancellationToken).ConfigureAwait(false); + + var meters = await db.Meters.AsNoTracking() + .Where(m => m.Id == fromMeterId || m.Id == toMeterId) + .ToListAsync(cancellationToken).ConfigureAwait(false); + var links = await AllLinksAsync(db, cancellationToken).ConfigureAwait(false); + var check = MeterLinkRules.CheckAdd( + meters.FirstOrDefault(m => m.Id == fromMeterId) is { } from ? MeterLinkRules.Describe(from) : null, + meters.FirstOrDefault(m => m.Id == toMeterId) is { } to ? MeterLinkRules.Describe(to) : null, + links, + fromMeterId, + toMeterId); + if (!check.IsAllowed) + { + return check; + } + + db.MeterLinks.Add(new MeterLink { FromMeterId = fromMeterId, ToMeterId = toMeterId }); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await tx.CommitAsync(cancellationToken).ConfigureAwait(false); + return check; + } + + /// Removes the link when the rules allow it. + /// The verdict; the link is removed only when it is allowed. + public async Task RemoveAsync(int linkId, CancellationToken cancellationToken = default) + { + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using var tx = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await LockAsync(db, cancellationToken).ConfigureAwait(false); + + var link = await db.MeterLinks.FirstOrDefaultAsync(l => l.Id == linkId, cancellationToken).ConfigureAwait(false); + if (link is null) + { + return MeterLinkCheck.Refused(MeterLinkRefusal.NotFound); + } + + var to = await db.Meters.AsNoTracking().FirstOrDefaultAsync(m => m.Id == link.ToMeterId, cancellationToken).ConfigureAwait(false); + var check = MeterLinkRules.CheckRemove(to is null ? null : MeterLinkRules.Describe(to)); + if (!check.IsAllowed) + { + return check; + } + + db.MeterLinks.Remove(link); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await tx.CommitAsync(cancellationToken).ConfigureAwait(false); + return check; + } + + private static async Task LoadAsync(MeterVaultDbContext db, int energyTypeId, CancellationToken cancellationToken) + { + var typeMeters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId) + .ToListAsync(cancellationToken).ConfigureAwait(false); + var ids = typeMeters.Select(m => m.Id).ToHashSet(); + var stored = await db.MeterLinks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); + var touching = stored.Where(l => ids.Contains(l.FromMeterId) || ids.Contains(l.ToMeterId)).ToList(); + + var otherIds = touching.SelectMany(l => new[] { l.FromMeterId, l.ToMeterId }).Where(id => !ids.Contains(id)).Distinct().ToList(); + var others = otherIds.Count == 0 + ? [] + : await db.Meters.AsNoTracking().Where(m => otherIds.Contains(m.Id)).ToListAsync(cancellationToken).ConfigureAwait(false); + + var meters = typeMeters.Select(MeterLinkRules.Describe) + .OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(m => m.Id) + .ToList(); + var byId = meters.Concat(others.Select(MeterLinkRules.Describe)).ToDictionary(m => m.Id); + var links = touching + .Select(l => new MeterLinkEntry(l.Id, l.FromMeterId, l.ToMeterId)) + .OrderBy(l => byId[l.FromMeterId].Name, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(l => byId[l.ToMeterId].Name, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(l => l.LinkId) + .ToList(); + + return new MeterLinkTopology( + energyTypeId, + meters, + links, + others.Select(MeterLinkRules.Describe).ToDictionary(m => m.Id), + [.. stored.Select(l => (l.FromMeterId, l.ToMeterId))]); + } + + private static async Task> AllLinksAsync(MeterVaultDbContext db, CancellationToken cancellationToken) + { + var rows = await db.MeterLinks.AsNoTracking() + .Select(l => new { l.FromMeterId, l.ToMeterId }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + return [.. rows.Select(r => (r.FromMeterId, r.ToMeterId))]; + } + + /// Serializes topology edits: the loop check is only sound while no other writer adds a link meanwhile. + private static Task LockAsync(MeterVaultDbContext db, CancellationToken cancellationToken) => + db.Database.ExecuteSqlRawAsync("LOCK TABLE meter_link IN SHARE ROW EXCLUSIVE MODE", cancellationToken); +} diff --git a/src/Infrastructure/Dashboard/MeterPeriodService.cs b/src/Infrastructure/Dashboard/MeterPeriodService.cs deleted file mode 100644 index 684adb5..0000000 --- a/src/Infrastructure/Dashboard/MeterPeriodService.cs +++ /dev/null @@ -1,159 +0,0 @@ -using Dapper; -using MeterVault.Core.Domain; -using MeterVault.Infrastructure.Options; -using MeterVault.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; - -namespace MeterVault.Infrastructure.Dashboard; - -/// -/// Answers the questions a meter is actually read for (SDD §8.6): how much this month, how that -/// compares with last month, where the year lands, what it costs. Register totals answer none of -/// those — a cumulative counter's value is an accident of when the meter was installed. -/// -public sealed class MeterPeriodService( - IDbContextFactory contextFactory, - Costing.CostService costs, - IOptions options) -{ - private readonly IDbContextFactory _contextFactory = contextFactory; - private readonly Costing.CostService _costs = costs; - private readonly MeterVaultOptions _options = options.Value; - - // Monthly buckets in the instance timezone, not UTC: a reading at 00:30 local on 1 January is - // 23:30 on 31 December in UTC, and would otherwise be booked to the wrong month (SDD §10). - private const string MonthlySql = """ - SELECT date_trunc('month', time AT TIME ZONE @tz)::date AS month, - sum(amount) AS amount - FROM consumption - WHERE meter_id = @meterId AND kind = @kind AND time >= @from - GROUP BY 1 - ORDER BY 1 - """; - - public async Task GetAsync(int meterId, CancellationToken cancellationToken = default) - { - await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - - var meter = await db.Meters.AsNoTracking() - .FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); - if (meter is null) - { - return null; - } - - // Virtual meters are evaluated on read and only materialize into `consumption` when a cost - // category references them (SDD §14.1), so summing that table would report a confident zero - // for a meter that is working fine. Report nothing and let the page say why. - if (meter.Mode == MeterMode.Virtual) - { - return null; - } - - var tz = ResolveTimeZone(); - var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz).Date); - - // A generation counter's output is generation, not consumption — reporting 0 kWh consumed - // for a working PV array is technically true and completely useless. - var isGeneration = meter.Mode == MeterMode.GenerationCounter; - var kind = isGeneration ? ConsumptionKind.Generation : ConsumptionKind.Consumption; - - // From the start of last year: enough for last-year totals and a rolling 12-month history. - var from = new DateTimeOffset(new DateTime(today.Year - 1, 1, 1, 0, 0, 0, DateTimeKind.Utc)); - - var months = (await db.Database.GetDbConnection() - .QueryAsync( - MonthlySql, - new { tz = _options.TimeZone, meterId, kind = (short)kind, from }) - .ConfigureAwait(false)) - .ToDictionary(r => r.Month, r => r.Amount); - - var costs = await LoadCostsAsync(meterId, from, cancellationToken).ConfigureAwait(false); - - var thisMonth = new DateOnly(today.Year, today.Month, 1); - var lastMonth = thisMonth.AddMonths(-1); - - var monthToDate = months.GetValueOrDefault(thisMonth); - var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month); - var monthPartial = today.Day < daysInMonth; - - var yearToDate = SumYear(months, today.Year); - var lastYear = SumYear(months, today.Year - 1); - var dayOfYear = today.DayOfYear; - var daysInYear = DateTime.IsLeapYear(today.Year) ? 366 : 365; - - var yearToDateCost = SumYear(costs, today.Year); - - return new MeterPeriodView( - Kind: kind, - Unit: meter.Unit, - Currency: _options.Currency, - MonthToDate: monthToDate, - MonthProjected: Project(monthToDate, today.Day, daysInMonth), - LastMonth: months.GetValueOrDefault(lastMonth), - YearToDate: yearToDate, - YearProjected: Project(yearToDate, dayOfYear, daysInYear), - LastYear: lastYear, - YearToDateCost: yearToDateCost, - YearProjectedCost: Project(yearToDateCost, dayOfYear, daysInYear), - LastYearCost: SumYear(costs, today.Year - 1), - MonthIsPartial: monthPartial, - Last12Months: BuildHistory(months, costs, thisMonth)); - } - - /// - /// Scales a partial period to its full length. Straight-line on elapsed days: it assumes the - /// rest of the period looks like what came before, which is wrong for anything seasonal but is - /// the honest reading of "at this rate". The UI marks these as projections. - /// - private static double Project(double soFar, int elapsed, int total) => - elapsed <= 0 ? soFar : soFar / elapsed * total; - - private static double SumYear(Dictionary byMonth, int year) => - byMonth.Where(kv => kv.Key.Year == year).Sum(kv => kv.Value); - - private static IReadOnlyList BuildHistory( - Dictionary months, Dictionary costs, DateOnly thisMonth) - { - var history = new List(12); - for (var offset = 11; offset >= 0; offset--) - { - var month = thisMonth.AddMonths(-offset); - history.Add(new MeterMonthPoint(month, months.GetValueOrDefault(month), costs.GetValueOrDefault(month))); - } - - // All-zero history means the meter has no normalized data yet; say nothing rather than - // drawing a flat line that looks like a meter reading zero. - return history.All(p => Math.Abs(p.Amount) < 1e-9) ? [] : history; - } - - private async Task> LoadCostsAsync( - int meterId, DateTimeOffset from, CancellationToken cancellationToken) - { - var buckets = await _costs.GetMeterCostsAsync( - meterId, from, DateTimeOffset.UtcNow, Costing.CostBucket.Month, cancellationToken).ConfigureAwait(false); - - return buckets - .GroupBy(b => new DateOnly(b.Period.Year, b.Period.Month, 1)) - .ToDictionary(g => g.Key, g => g.Sum(b => b.Cost)); - } - - /// - /// Dapper row shape — it maps by column name, so a value tuple will not do, and Npgsql surfaces - /// a date column as . - /// - private sealed record MonthlyRow(DateOnly Month, double Amount); - - private TimeZoneInfo ResolveTimeZone() - { - try - { - return TimeZoneInfo.FindSystemTimeZoneById(_options.TimeZone); - } - catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException) - { - return TimeZoneInfo.Utc; - } - } -} diff --git a/src/Infrastructure/Dashboard/OverviewModels.cs b/src/Infrastructure/Dashboard/OverviewModels.cs new file mode 100644 index 0000000..ffc808d --- /dev/null +++ b/src/Infrastructure/Dashboard/OverviewModels.cs @@ -0,0 +1,367 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; + +namespace MeterVault.Infrastructure.Dashboard; + +// The Overview's read model (brief §7.1, D-07 – D-09, D-19, D-42): the portfolio's quantities and bill for one resolved +// period, the bill of the comparison priced in the paired buckets, and what the page derives from them — per energy +// type, per composition slice, per bill line — as data. Names are user data; everything else is a code the page words. + +/// An energy type as the Overview lists it: its id, its name (user data) and how many meters it has. +public sealed record OverviewEnergyType(int Id, string Name, int MeterCount) +{ + public bool HasMeters => MeterCount > 0; +} + +/// How a cost change was measured (D-07). +public enum CostChangeBasis +{ + /// No comparison was requested, or it does not apply to the period. + NoComparison, + + /// Both periods are complete: their totals are compared. + WholePeriod, + + /// Only the paired buckets both sides cover completely are compared. + MatchedBuckets, + + /// No paired bucket is complete on both sides: values only, no change (D-07). + NotComparable, +} + +/// +/// A cost change over the coverage both periods share (D-07, D-08): the two amounts compared, the change between +/// them, how they were matched, and the matched stretches for the comparison summary. +/// +/// The change; unavailable when there is nothing to compare. +/// The current amount over the matched coverage. +/// The comparison amount over the matched coverage. +/// How the amounts were matched. +/// The matched stretches (whole buckets); not comparable when empty. +public sealed record CostChange(Change Change, double? Current, double? Previous, CostChangeBasis Basis, MatchedCoverageResult Matched) +{ + public static CostChange NoComparison { get; } = + new(Change.Unavailable, null, null, CostChangeBasis.NoComparison, MatchedCoverageResult.NotComparable); + + public static CostChange NotComparable { get; } = + new(Change.Unavailable, null, null, CostChangeBasis.NotComparable, MatchedCoverageResult.NotComparable); + + /// True when the change is stated over less than the requested periods. + public bool IsPartial => Basis == CostChangeBasis.MatchedBuckets; +} + +/// +/// The cost change rule of the Overview (D-07): compare complete figures only. When both period totals are complete +/// (priced over available quantities), the totals are compared; otherwise the paired buckets both sides have complete +/// are added up and compared — whole buckets, never a partial month against a whole one; without any, the figures are +/// not comparable and only the values are shown. Money changes use a half-cent tolerance. +/// +public static class OverviewComparison +{ + /// Half a cent: a money difference or baseline that displays as zero counts as zero. + public const double Tolerance = 0.005; + + /// True when a cost figure is complete: priced, over available quantities, with a value. + public static bool IsComplete(CostAmount? amount) => + amount is { Status: CostStatus.Priced, Availability: BucketStatus.Available, Cost: { } cost } && double.IsFinite(cost); + + /// The change from the comparison figure to the current one over what both cover completely. + /// The current figure per bucket. + /// The current figure over the period. + /// The comparison figure per paired bucket; null without a comparison. + /// The comparison figure over its period; null without a comparison. + /// The buckets paired by index (A-10). + public static CostChange Between( + IReadOnlyList? current, + CostAmount? currentTotal, + IReadOnlyList? previous, + CostAmount? previousTotal, + IReadOnlyList pairs) + { + ArgumentNullException.ThrowIfNull(pairs); + + if (previous is null || previousTotal is null || pairs.Count == 0) + { + return CostChange.NoComparison; + } + + if (IsComplete(currentTotal) && IsComplete(previousTotal)) + { + var now = currentTotal!.Cost!.Value; + var before = previousTotal.Cost!.Value; + return new CostChange( + Change.Between(now, before, Tolerance), now, before, CostChangeBasis.WholePeriod, Match(pairs, [.. Enumerable.Range(0, pairs.Count)])); + } + + var matched = new List(); + double sumNow = 0, sumBefore = 0; + for (var i = 0; i < pairs.Count; i++) + { + var a = current is not null && i < current.Count ? current[i] : null; + var b = i < previous.Count ? previous[i] : null; + if (IsComplete(a) && IsComplete(b)) + { + matched.Add(i); + sumNow += a!.Cost!.Value; + sumBefore += b!.Cost!.Value; + } + } + + return matched.Count == 0 + ? CostChange.NotComparable + : new CostChange(Change.Between(sumNow, sumBefore, Tolerance), sumNow, sumBefore, CostChangeBasis.MatchedBuckets, Match(pairs, matched)); + } + + /// The matched stretches of the given bucket indices: each run of adjacent buckets is one piece. + public static MatchedCoverageResult Match(IReadOnlyList pairs, IReadOnlyList indices) + { + ArgumentNullException.ThrowIfNull(pairs); + ArgumentNullException.ThrowIfNull(indices); + + var pieces = new List(); + var start = -1; + for (var k = 0; k < indices.Count; k++) + { + var index = indices[k]; + start = start < 0 ? index : start; + var endsRun = k == indices.Count - 1 || indices[k + 1] != index + 1; + if (endsRun) + { + pieces.Add(new MatchedPiece( + Range(pairs[start].Current, pairs[index].Current), + Range(pairs[start].Comparison, pairs[index].Comparison))); + start = -1; + } + } + + if (pieces.Count == 0) + { + return MatchedCoverageResult.NotComparable; + } + + return new MatchedCoverageResult( + Range(pieces[0].Current, pieces[^1].Current), + Range(pieces[0].Comparison, pieces[^1].Comparison), + pieces); + } + + private static MatchedRange Range(AnalysisBucket first, AnalysisBucket last) => + new(first.From, last.To, first.FirstDay, last.EndDay > last.FirstDay ? last.EndDay.AddDays(-1) : last.FirstDay); + + private static MatchedRange Range(MatchedRange first, MatchedRange last) => new(first.From, last.To, first.FirstDay, last.LastDay); +} + +/// A projection of a to-date cost to the end of its period (D-09): the value and the days it rests on. +/// The whole days elapsed that the straight line is drawn from. +/// The projected cost over the whole named period. +public sealed record CostProjection(int Days, double Value); + +/// +/// When the Overview may project a cost (D-09), and what: only a month or year to date, only from a complete figure +/// (priced over available quantities — coverage that stops early, stale sources and data coarser than the period all +/// leave it partial, so they suppress the projection), and only after 7 days of a month or 30 of a year. The metered +/// usage (net of feed-in credit) and the standing charge are drawn straight on at their rate per elapsed day; manual +/// costs, booked once, are kept as they are. +/// +public static class OverviewProjection +{ + /// The fewest elapsed days a month projection rests on. + public const int MinimumMonthDays = 7; + + /// The fewest elapsed days a year projection rests on. + public const int MinimumYearDays = 30; + + /// The projection of over , or null when D-09 suppresses it. + public static CostProjection? For(ResolvedPeriod period, CostAmount total) + { + ArgumentNullException.ThrowIfNull(period); + ArgumentNullException.ThrowIfNull(total); + + var minimum = period.Preset switch + { + PeriodPreset.MonthToDate => MinimumMonthDays, + PeriodPreset.YearToDate => MinimumYearDays, + _ => (int?)null, + }; + if (minimum is not { } least || !period.IsToDate || !OverviewComparison.IsComplete(total) || period.NominalEnd() is not { } end) + { + return null; + } + + var elapsed = (period.Now - period.From).TotalDays; + var whole = (end - period.From).TotalDays; + if (elapsed < least || whole <= elapsed) + { + return null; + } + + var running = (total.Usage ?? 0) + (total.StandingCharge ?? 0) - (total.FeedInCredit ?? 0); + var value = (running / elapsed * whole) + (total.Manual ?? 0); + return double.IsFinite(value) ? new CostProjection((int)Math.Floor(elapsed), value) : null; + } +} + +/// One energy type on the Overview: its measures (D-22), its part of the bill and its change, and its freshness. +/// The energy type. +/// Its measure totals, in the order use, grid import, generation, export, runtime (one per unit). +/// Its part of the bill; null when it has none. +/// Its part of the comparison bill, priced in the paired buckets. +/// The change of its cost over the matched coverage. +/// Its measures' freshness together (D-18). +public sealed record OverviewTypeFigures( + OverviewEnergyType Type, + IReadOnlyList Measures, + EnergyTypeCostFigure? Cost, + EnergyTypeCostFigure? PreviousCost, + CostChange CostChange, + Freshness Freshness) +{ + /// True when no measure has a value for the period. + public bool HasNoValues => Measures.All(m => m.Total.Value is null) && Cost?.Total.Cost is null; +} + +/// What a row of the Overview's change table is. +public enum OverviewRowKind +{ + /// A disjoint cost category (a composition slice, D-42). + Category, + + /// What no disjoint category holds. + Uncategorized, + + /// A type, global or meter standing charge (D-40, A-18). + StandingCharge, + + /// A bill line: a billed meter, a subsection at its own price, or an export credit. + Line, + + /// The manual costs booked in the period (D-41). + ManualCosts, +} + +/// +/// A row of the Overview's change table: what it is, the ids it names, its current and comparison figure and the +/// change over the coverage both share. The rows of one grouping add up to the bill. +/// +/// What the row is. +/// A category's or meter's name, or a standing charge's type or meter name (user data); empty for the rows the page names. +/// The figure in the period; null when the row does not occur in it. +/// The figure in the comparison; null when it does not occur there or nothing is compared. +/// The change over the matched coverage. +public sealed record OverviewChangeRow(OverviewRowKind Kind, string Name, CostAmount? Current, CostAmount? Previous, CostChange Change) +{ + /// The category, for a category row. + public int? CategoryId { get; init; } + + /// The category's colour, for a category row. + public string? ColorHex { get; init; } + + /// The standing charge, for a standing-charge row. + public StandingChargeKey? StandingCharge { get; init; } + + /// The priced meter, for a line row. + public int? MeterId { get; init; } + + /// How a line is priced. + public BillLineKind? LineKind { get; init; } + + /// For a source line of a virtual meter's source costs, that virtual meter. + public int? ForMeterId { get; init; } + + /// The energy type of a line or a type standing charge. + public int? EnergyTypeId { get; init; } + + /// The size of the change, for ranking; null when there is none. + public double? Magnitude => Change.Change.Absolute is { } difference ? Math.Abs(difference) : null; +} + +/// +/// The Overview (brief §7.1): the portfolio's quantities and bill for one resolved period and one bucket plan, the bill +/// of the comparison period priced in the paired buckets, the energy types, and what the page derives from them. +/// +/// The period, resolved once (D-01, D-03). +/// Every type's measures and every meter's series (D-22), with the quantity comparison (D-07). +/// The bill with its category composition (D-34 – D-42), in the quantities' buckets. +/// How the comparison resolved (D-06). +/// The comparison's bill in the paired buckets; null when the comparison does not apply. +/// The buckets paired by index (A-10); empty without a comparison. +/// Every energy type, ordered by id. +public sealed record DashboardOverview( + ResolvedPeriod Period, + AnalysisResult Quantities, + CostAnalysis Cost, + ComparisonResolution Comparison, + CostAnalysis? PreviousCost, + IReadOnlyList Pairs, + IReadOnlyList EnergyTypes) +{ + /// What exists of the cost setup, when something basic is missing (no meters, no categories); null otherwise. + public CostSetup? Setup { get; init; } + + /// The buckets every panel shares. + public BucketPlan Plan => Quantities.Plan; + + /// True when the bucket size would exceed the point limit (D-05): nothing was read. + public bool IsRefused => Quantities.Refusal != AnalysisRefusal.None || Cost.Refusal != CostRefusal.None; + + /// The whole range lies after now (D-04). + public bool NotYetOccurred => Period.HasNotStarted(); + + /// Some analysis data is being rebuilt (D-16): "being prepared", never "no data". + public bool IsPending => Quantities.Measures.Any(m => m.IsPending); + + /// + /// What the instance has data for, meters and manual costs alike (D-19), capped at now — "data is available from … + /// to …" and the target of "go to latest data". + /// + public AvailableRange? Availability => AvailableRange.Union([Quantities.Availability.Quantity, Cost.Availability.Range], Period.Zone); + + /// The latest month with data and what it rests on (D-19). + public LatestPeriod? Latest => Cost.Availability.Latest ?? Quantities.Availability.LatestQuantity; + + /// + /// True when nothing at all is known for the period — no measure is covered and nothing is charged — while it has + /// started: the page says "no data for this period" and offers the latest data instead of silently showing it. + /// + public bool HasNoData => + !IsRefused && !NotYetOccurred && !IsPending + && Quantities.Measures.All(m => m.Total.Status == BucketStatus.Missing) + && Cost.Total.Cost is null; + + /// The change of the bill over the matched coverage (D-07). + public CostChange CostChange { get; init; } = CostChange.NoComparison; + + /// The bill projected to the end of a month or year to date, when D-09 allows. + public CostProjection? Projection { get; init; } + + /// The energy types with meters, each with its measures and cost. + public IReadOnlyList Types { get; init; } = []; + + /// The composition slices with their change, largest change first; they add up to the bill. + public IReadOnlyList CategoryChanges { get; init; } = []; + + /// The bill lines, standing charges and manual costs with their change, largest change first; they add up to the bill. + public IReadOnlyList LineChanges { get; init; } = []; + + /// Every meter's name by id (user data). + public IReadOnlyDictionary MeterNames { get; init; } = new Dictionary(); + + /// + /// The coarsest resolution of the meters any measure counts (for drilling into a bucket of the bill, D-51); null + /// without data — a monthly import is never drilled into days that cannot resolve it. + /// + public ResolutionClass? CoarsestResolution => + Quantities.Measures.Select(ResolutionOf).Where(r => r is not null).Max(); + + /// The coarsest resolution behind a measure: the reader's, that of the meters it counts (D-51). + public ResolutionClass? ResolutionOf(AnalysisSeries measure) + { + ArgumentNullException.ThrowIfNull(measure); + + return measure.Resolution; + } +} diff --git a/src/Infrastructure/Dashboard/SolarModels.cs b/src/Infrastructure/Dashboard/SolarModels.cs index 5a4b1eb..9791ca9 100644 --- a/src/Infrastructure/Dashboard/SolarModels.cs +++ b/src/Infrastructure/Dashboard/SolarModels.cs @@ -1,37 +1,181 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; + namespace MeterVault.Infrastructure.Dashboard; -/// One month of the PV panel: generation, and (when role-tagged meters exist) the -/// self-consumption / grid-draw / savings split that reproduces the sheet's Netz-Einsparung column. -public sealed record SolarMonth( - DateOnly Period, - double Generation, - double? SelfConsumption, - double? GridImport, - double? TotalLoad, - double? Savings); +// The Solar view's read model (SDD §8.4, brief §7.5, D-54). One section per energy type that has generation: its +// generation (the type's generation measure, so a virtual sum over counted meters is never added twice), and — where the +// meters' roles allow — self-consumption, feed-in, site use, autarky and the value of the generation, each with its own +// status. Everything is data; the page words it. -/// Per-generation-meter total over the selected period (for the ranked list). -public sealed record GenerationMeterRow(int MeterId, string Name, double Generation); +/// What the Solar view reads: a period resolved once by the page (D-01), a bucket size and a comparison. +/// The period, resolved in the instance zone; its is the request's "now". +public sealed record SolarRequest(ResolvedPeriod Period) +{ + /// The bucket size; automatic by default (D-05). + public BucketSize Bucket { get; init; } = BucketSize.Auto; + + /// What to compare with (D-06); none by default. + public ComparisonRequest Comparison { get; init; } = ComparisonRequest.None; +} + +/// How a solar figure is obtained from the meters (brief §7.5: named next to the figure). +public enum SolarBasis +{ + /// Read straight from meters: the generation measure, or the meters holding a role. + Measured, + + /// Self-consumption = total consumption (total_load) − grid import (grid_import). + LoadMinusImport, + + /// Self-consumption = generation − grid export (grid_export). + GenerationMinusExport, + + /// Feed-in = generation − self-consumption, without a grid export meter. + GenerationMinusSelfConsumption, + + /// Site use = self-consumption + grid import, without a total consumption meter. + SelfConsumptionPlusImport, +} /// -/// The PV / solar panel read model (SDD §8.4): total generation plus, when the install has tagged -/// a total_load and grid_import meter, self-consumption, autarky %, self-consumption % -/// and savings (Ersparnis). Derived metrics are null when no role config exists. +/// One solar figure (brief §4.3): a value per bucket and over the period, each with its status — a bucket an input has no +/// data for is unknown, never a zero — how it was obtained, and the same over the comparison period when one was asked. /// -public sealed record SolarSummary( - double Generation, - double? TotalLoad, - double? GridImport, - double? SelfConsumption, - double? Autarky, - double? SelfConsumptionRatio, - double? Savings, - IReadOnlyList Meters, - IReadOnlyList Months) +/// How it was obtained. +/// Its normalized unit (D-20), never assumed. +/// One value per bucket of the site's plan. +/// The period total: the inputs' totals when both are complete, otherwise what the buckets both inputs know add up to (partial). +public sealed record SolarFigure(SolarBasis Basis, string Unit, IReadOnlyList Values, BucketValue Total) { - /// True when the install has the role-tagged meters needed for self-consumption metrics. - public bool HasLoadContext => TotalLoad is not null && GridImport is not null; + /// The meters the figure rests on, ascending. + public IReadOnlyList MeterIds { get; init; } = []; - /// True when at least one generation meter exists. - public bool HasGeneration => Meters.Count > 0; + /// + /// The units of the inputs. More than one means they could not be combined (D-20): every value is + /// , and the page names the units. + /// + public IReadOnlyList InputUnits { get; init; } = []; + + /// True when the inputs are in different units, so the figure cannot be calculated. + public bool UnitsDiffer => InputUnits.Count > 1; + + /// One value per paired comparison bucket (A-10), when a comparison was read. + public IReadOnlyList? ComparisonValues { get; init; } + + /// The figure over the comparison period. + public BucketValue? ComparisonTotal { get; init; } + + /// + /// The change against the comparison: over the matched coverage for a measured figure (D-07); for a derived one between + /// the two totals when both are complete, otherwise between the paired buckets complete on both sides — unavailable + /// when there are none. + /// + public Change Change { get; init; } = Change.Unavailable; +} + +/// A meter named by the view (a role holder, a setup candidate). +public sealed record SolarMeterRef(int MeterId, string Name); + +/// +/// One role of the energy type as the view needs it (D-21, A-07): who holds it, and — when nobody does — which meters of +/// the type could (their mode may hold it and they hold no other role), for a scoped setup path. +/// +public sealed record SolarRoleStatus(MeterRole Role, IReadOnlyList Holders, IReadOnlyList Candidates) +{ + public bool IsSet => Holders.Count > 0; +} + +/// +/// A generation meter of the type — a GenerationCounter or a virtual meter whose result is generation — with its +/// own total, and whether the type's generation total counts it (a virtual sum over counted meters is a view, D-22/D-23). +/// +public sealed record SolarMeter(int MeterId, string Name, bool IsVirtual, bool IsCounted, string Unit, BucketValue Total); + +/// A money figure of the view per bucket and over the period, priced by the cost engine's rules. +/// One figure per bucket. +/// Over the period, priced over its own local months (D-36). +/// The meters whose price or credit it is. +public sealed record SolarMoney(IReadOnlyList Buckets, CostAmount Total, IReadOnlyList MeterIds); + +/// +/// One energy type's solar section: the reader's result for the type (plan, availability, comparison, problems) and the +/// figures the view derives from it. +/// +/// The energy type. +/// Its display name (user data). +/// The type read with its meters' own series (D-15). +public sealed record SolarSite(int EnergyTypeId, string EnergyTypeName, AnalysisResult Quantities) +{ + /// The unit the derived figures are in: the generation measure's that matches the role meters, else the first. + public string? Unit { get; init; } + + /// The generation measure in ; null when the type counts no generation meter. + public AnalysisSeries? Generation { get; init; } + + /// Generation measures in other units — never added to (D-22 rule 4). + public IReadOnlyList OtherGeneration { get; init; } = []; + + /// Solar energy used on site; null when the roles do not allow it. + public SolarFigure? SelfConsumption { get; init; } + + /// What went into the grid; null when the roles do not allow it. + public SolarFigure? FeedIn { get; init; } + + /// Everything the site used; null when the roles do not allow it. + public SolarFigure? SiteUse { get; init; } + + /// What was bought from the grid (the grid_import meter); null without one. + public SolarFigure? GridImport { get; init; } + + /// Self-consumption as a share of site use, in percent; null when either is not available. + public BucketValue? Autarky { get; init; } + + /// Self-consumption as a share of generation, in percent; null when either is not available. + public BucketValue? SelfConsumptionShare { get; init; } + + /// Self-consumption priced at the grid import's unit price: the grid purchase it avoided. + public SolarMoney? Savings { get; init; } + + /// The feed-in credit of the grid export meter(s) at the feed-in price, as the bill credits it (D-34). + public SolarMoney? FeedInCredit { get; init; } + + /// The generation meters of the type, counted ones first. + public IReadOnlyList Meters { get; init; } = []; + + /// Total consumption, grid import and grid export, in that order. + public IReadOnlyList Roles { get; init; } = []; + + /// The reader's problems about the meters the figures rest on (D-53). + public IReadOnlyList Problems { get; init; } = []; + + /// What pricing the savings and the credit needed and did not get (D-38, D-53). + public IReadOnlyList CostAttention { get; init; } = []; + + /// What the type has data for (D-19). + public AvailableRange? Availability => Quantities.Availability.Quantity; + + /// The type's generation is being rebuilt (D-16): "being prepared", never "no data". + public bool IsPending => Generation?.IsPending == true; + + /// The role, or a status saying nobody holds it. + public SolarRoleStatus RoleOf(MeterRole role) => + Roles.FirstOrDefault(r => r.Role == role) ?? new SolarRoleStatus(role, [], []); +} + +/// The Solar view: one section per energy type with generation, in the plan of the first. +/// The requested period. +/// The instance currency the money figures are in (D-43). +/// One section per energy type with a generation meter, by energy type id; empty when there is none. +public sealed record SolarAnalysis(ResolvedPeriod Period, string Currency, IReadOnlyList Sites) +{ + /// The buckets every section is read in; null without sections. + public BucketPlan? Plan => Sites.Count > 0 ? Sites[0].Quantities.Plan : null; + + /// True when the request was refused before anything was read (too many points). + public bool IsRefused => Sites.Count > 0 && Sites[0].Quantities.Refusal != AnalysisRefusal.None; } diff --git a/src/Infrastructure/Dashboard/SolarService.cs b/src/Infrastructure/Dashboard/SolarService.cs index bfefd52..0010f0b 100644 --- a/src/Infrastructure/Dashboard/SolarService.cs +++ b/src/Infrastructure/Dashboard/SolarService.cs @@ -1,125 +1,666 @@ -using Dapper; -using MeterVault.Core.Costing; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; using MeterVault.Core.Domain; -using MeterVault.Infrastructure.Options; +using MeterVault.Core.Normalization; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; namespace MeterVault.Infrastructure.Dashboard; /// -/// Read model for the PV / solar panel (SDD §8.4). Generation comes from every -/// meter; self-consumption / autarky / savings are derived -/// from the meters tagged and -/// — so nothing is hardcoded by meter name. Reads only the aggregated consumption hypertable -/// (monthly, in the instance timezone) via Dapper; safe from a Blazor circuit via a DbContext factory. +/// Read model for the Solar view (SDD §8.4, brief §7.5, D-54): per energy type with generation, its generation and — as +/// far as the meters' roles allow — self-consumption, feed-in, site use, autarky and the value of the generation. Nothing +/// is found by name: generation meters by mode (and virtual meters by their result kind), the rest by effective role +/// (, A-07). /// -public sealed class SolarService( - IDbContextFactory contextFactory, IOptions? options = null) +/// +/// +/// What is read. One read of the energy type through the shared analysis reader (D-15) in the request's buckets +/// and comparison, with every meter's own series. Generation is the type's generation measure (D-22), so a virtual sum +/// over counted meters — the seeded Summe Solar — is listed but never added a second time. Role meters are read as +/// themselves: before a meter is installed its role's figure is unknown, not a zero, so self-consumption is not "all of +/// the load" or "minus the grid" there (A05). +/// +/// +/// What is derived. Self-consumption is total consumption − grid import when both roles are held (the sheet's +/// Netz Einsparung), otherwise generation − grid export. Feed-in is the grid export meter, otherwise generation − +/// self-consumption; site use the total consumption meter, otherwise self-consumption + grid import. A bucket either input +/// has no data for is unknown; a total is the inputs' totals when both are complete, and otherwise only what the buckets +/// both inputs know add up to, marked partial. Figures in different units are never combined (D-20). +/// +/// +/// What it is worth. Self-consumption is priced at the grid import's unit price by the cost engine's calculator — +/// month by month at the price of the 15th, in the meter's normalized unit (D-36, D-37); a month without a price has no +/// savings rather than free ones (D-38). The feed-in credit is the cost reader's own feed-in line of the grid export meter, +/// exactly as the bill credits it (D-34). It never reads the clock: "now" is the period's (D-01). +/// +/// +public sealed class SolarService(IDbContextFactory contextFactory, AnalysisReader reader, CostReader costs) { private readonly IDbContextFactory _contextFactory = contextFactory; + private readonly AnalysisReader _reader = reader; + private readonly CostReader _costs = costs; + + /// The zone periods must be resolved in. + public TimeZoneInfo Zone => _reader.Zone; + + /// Reads the Solar view for a period. + /// The period was resolved in another zone than the readers'. + public async Task GetAsync(SolarRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var catalog = await AnalysisCatalog.LoadAsync(db, _reader.Zone, cancellationToken).ConfigureAwait(false); + var typeIds = SiteTypes(catalog); + if (typeIds.Count == 0) + { + return new SolarAnalysis(request.Period, _costs.Currency, []); + } + + var names = await db.EnergyTypes.AsNoTracking() + .ToDictionaryAsync(t => (int)t.Id, t => t.DisplayName, cancellationToken).ConfigureAwait(false); + var tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Id).ToListAsync(cancellationToken).ConfigureAwait(false); + var book = TariffBook.Create(tariffs, _costs.Currency); + + // Every section shares the first one's buckets, so the page has one toolbar and one axis. + BucketPlan? plan = null; + var sites = new List(); + foreach (var typeId in typeIds) + { + var run = new SiteRun(this, db, catalog, book, request, typeId, names.GetValueOrDefault(typeId) ?? string.Empty); + var site = await run.ExecuteAsync(plan, cancellationToken).ConfigureAwait(false); + plan ??= site.Quantities.Plan; + sites.Add(site); + } + + return new SolarAnalysis(request.Period, _costs.Currency, sites); + } /// - /// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides - /// intervals at, so a share stamped at a month's last second is read back under that month. + /// What the energy types with generation have data for as of (D-19), for the all preset; + /// null when there is none. /// - private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone; - private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone); - - public async Task GetSummaryAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default) + public async Task GetAvailabilityAsync(DateTimeOffset now, CancellationToken cancellationToken = default) { - await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - - var meters = await db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); - var generationMeters = meters.Where(m => m.Mode == MeterMode.GenerationCounter).ToList(); - var loadMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.TotalLoad); - var gridMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.GridImport); - - var fromUtc = InstanceTimeZone.StartOf(from, _zone); - var toUtc = InstanceTimeZone.StartOf(to, _zone); - - // Monthly generation per generation meter. - var genByMeter = new Dictionary>(); - foreach (var meter in generationMeters) + var catalog = await _reader.LoadCatalogAsync(cancellationToken).ConfigureAwait(false); + var ranges = new List(); + foreach (var typeId in SiteTypes(catalog)) { - genByMeter[meter.Id] = await MonthlyAsync(db, meter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false); + var availability = await _reader.GetAvailabilityAsync(AnalysisScope.ForEnergyType(typeId), now, cancellationToken).ConfigureAwait(false); + ranges.Add(availability.Quantity); } - var loadByMonth = loadMeter is null - ? null - : await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false); - var gridByMonth = gridMeter is null - ? null - : await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false); + return AvailableRange.Union(ranges, _reader.Zone); + } - var tariffs = gridMeter is null - ? [] - : await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); + /// The energy types with a generation meter: a generation counter, or a virtual meter whose result is generation. + internal static IReadOnlyList SiteTypes(AnalysisCatalog catalog) => + [.. catalog.Meters.Values.Where(IsGenerationMeter).Select(m => m.EnergyTypeId).Distinct().Order()]; - // Union of all months that carry any data. - var periods = new SortedSet(); - foreach (var series in genByMeter.Values) + private static bool IsGenerationMeter(AnalysisMeter meter) => + meter.Meter.Mode == MeterMode.GenerationCounter || (meter.IsVirtual && meter.Quantity.Kind == QuantityKind.Generation); + + // ------------------------------------------------------------------------------------------------ arithmetic + + /// + /// + × for one bucket: unknown when either is (a + /// pending, invalid or too coarse input decides the status), partial when either is, derived from both. + /// + internal static BucketValue Combine(BucketValue a, BucketValue b, double sign) + { + foreach (var status in (ReadOnlySpan)[BucketStatus.Pending, BucketStatus.Invalid, BucketStatus.Unresolved]) { - periods.UnionWith(series.Keys); - } - - if (loadByMonth is not null) - { - periods.UnionWith(loadByMonth.Keys); - } - - var months = new List(); - foreach (var period in periods) - { - var generation = genByMeter.Values.Sum(s => s.GetValueOrDefault(period)); - - double? load = loadByMonth?.GetValueOrDefault(period); - double? grid = gridByMonth?.GetValueOrDefault(period); - double? self = load is not null && grid is not null ? load - grid : null; - - double? savings = null; - if (self is { } selfValue && gridMeter is not null) + if (a.Status == status) { - var price = TariffResolver.ResolveValue( - tariffs, TariffComponent.UnitPrice, gridMeter.Id, gridMeter.EnergyTypeId, - new DateOnly(period.Year, period.Month, 15)); - savings = selfValue * price; + return Spoiled(a); } - months.Add(new SolarMonth(period, generation, self, grid, load, savings)); + if (b.Status == status) + { + return Spoiled(b); + } } - var meterRows = generationMeters - .Select(m => new GenerationMeterRow(m.Id, m.Name, genByMeter[m.Id].Values.Sum())) - .OrderByDescending(r => r.Generation) - .ToList(); + if (a.Value is not { } left || b.Value is not { } right) + { + var bothMissing = a.Value is null && b.Value is null; + var missing = a.Value is null ? a : b; + return new BucketValue( + null, BucketStatus.Missing, Provenance.None, bothMissing ? ValueIssue.NoCoverage : ValueIssue.MissingSource, missing.IssueDetail, missing.DependencyPath); + } - var totalGeneration = meterRows.Sum(r => r.Generation); - double? totalLoad = loadByMonth?.Values.Sum(); - double? totalGrid = gridByMonth?.Values.Sum(); - double? totalSelf = totalLoad is not null && totalGrid is not null ? totalLoad - totalGrid : null; - double? autarky = totalSelf is not null && totalLoad is > 0 ? totalSelf / totalLoad : null; - double? selfRatio = totalSelf is not null && totalGeneration > 0 ? totalSelf / totalGeneration : null; - double? totalSavings = months.Any(m => m.Savings is not null) ? months.Sum(m => m.Savings ?? 0) : null; + var value = left + (sign * right); + if (!double.IsFinite(value)) + { + return new BucketValue(null, BucketStatus.Invalid, Provenance.None, ValueIssue.NonFinite); + } - return new SolarSummary( - totalGeneration, totalLoad, totalGrid, totalSelf, autarky, selfRatio, totalSavings, meterRows, months); + var provenance = ProvenanceRules.Derive([a.Provenance, b.Provenance]); + var partial = a.Status == BucketStatus.Partial ? a : b.Status == BucketStatus.Partial ? b : null; + return partial is null + ? new BucketValue(value, BucketStatus.Available, provenance) + : new BucketValue(value, BucketStatus.Partial, provenance, Issue(partial, ValueIssue.PartialCoverage), partial.IssueDetail, partial.DependencyPath); } - private static async Task> MonthlyAsync( - MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, string tz, CancellationToken cancellationToken) + /// + /// The period total of a + sign × b: from the two totals when both are complete; otherwise what the buckets both + /// inputs know add up to, partial — never the difference of two totals over different stretches of time. + /// + internal static BucketValue CombineTotal(BucketValue a, BucketValue b, double sign, IReadOnlyList buckets) { - const string sql = - "SELECT (time_bucket(INTERVAL '1 month', \"time\", @tz) AT TIME ZONE @tz)::date AS period, " + - "sum(amount) AS amount " + - "FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " + - "GROUP BY period"; + if (a.Status == BucketStatus.Available && b.Status == BucketStatus.Available) + { + return Combine(a, b, sign); + } - var connection = db.Database.GetDbConnection(); - var command = new CommandDefinition(sql, new { meterId, from, to, tz }, cancellationToken: cancellationToken); - var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false); - return rows.ToDictionary(r => r.Period, r => r.Amount); + foreach (var status in (ReadOnlySpan)[BucketStatus.Pending, BucketStatus.Invalid]) + { + if (a.Status == status) + { + return Spoiled(a); + } + + if (b.Status == status) + { + return Spoiled(b); + } + } + + return SumOfKnown(buckets, a.Status == BucketStatus.Unresolved ? a : b.Status == BucketStatus.Unresolved ? b : null); } + /// + /// A share in percent: from the two totals when both are complete, otherwise over the buckets both know (partial). + /// Invalid when the units differ or the base is not positive. + /// + internal static BucketValue Share(SolarFigure part, SolarFigure whole) + { + if (!Units.AreSame(part.Unit, whole.Unit) || part.UnitsDiffer || whole.UnitsDiffer) + { + return new BucketValue(null, BucketStatus.Invalid, Provenance.None); + } + + double numerator; + double denominator; + var status = BucketStatus.Available; + if (part.Total.Status == BucketStatus.Available && whole.Total.Status == BucketStatus.Available) + { + numerator = part.Total.Value!.Value; + denominator = whole.Total.Value!.Value; + } + else + { + var matched = part.Values.Zip(whole.Values).Where(p => p.First.Value is not null && p.Second.Value is not null).ToList(); + if (matched.Count == 0) + { + var spoiled = part.Total.Status is BucketStatus.Pending or BucketStatus.Invalid ? part.Total + : whole.Total.Status is BucketStatus.Pending or BucketStatus.Invalid ? whole.Total + : null; + return spoiled is not null ? Spoiled(spoiled) : BucketValue.Missing(ValueIssue.MissingSource); + } + + numerator = matched.Sum(p => p.First.Value!.Value); + denominator = matched.Sum(p => p.Second.Value!.Value); + status = BucketStatus.Partial; + } + + if (denominator <= Change.Tolerance) + { + return new BucketValue(null, BucketStatus.Invalid, Provenance.None, ValueIssue.NonFinite); + } + + var provenance = ProvenanceRules.Derive([part.Total.Provenance, whole.Total.Provenance]); + return status == BucketStatus.Available + ? new BucketValue(numerator / denominator * 100, BucketStatus.Available, provenance) + : new BucketValue(numerator / denominator * 100, BucketStatus.Partial, provenance, ValueIssue.PartialCoverage); + } + + /// + × as a figure, bucket by bucket and in total. + internal static SolarFigure Derive(SolarBasis basis, SolarFigure a, SolarFigure b, double sign) + { + IReadOnlyList meterIds = [.. a.MeterIds.Concat(b.MeterIds).Distinct().Order()]; + if (!Units.AreSame(a.Unit, b.Unit) || a.UnitsDiffer || b.UnitsDiffer) + { + // Never combined across units (D-20): the figure cannot be calculated, and the page names both units. + var invalid = new BucketValue(null, BucketStatus.Invalid, Provenance.None); + return new SolarFigure(basis, a.Unit, [.. a.Values.Select(_ => invalid)], invalid) + { + MeterIds = meterIds, + InputUnits = [.. a.InputUnits.Concat(b.InputUnits).Distinct(Units.Comparer)], + }; + } + + List values = [.. a.Values.Zip(b.Values, (x, y) => Combine(x, y, sign))]; + var total = CombineTotal(a.Total, b.Total, sign, values); + + IReadOnlyList? comparisonValues = null; + BucketValue? comparisonTotal = null; + if (a is { ComparisonValues: { } ac, ComparisonTotal: { } at } && b is { ComparisonValues: { } bc, ComparisonTotal: { } bt }) + { + List paired = [.. ac.Zip(bc, (x, y) => Combine(x, y, sign))]; + comparisonValues = paired; + comparisonTotal = CombineTotal(at, bt, sign, paired); + } + + var change = MatchedChange(total, values, comparisonTotal, comparisonValues); + + return new SolarFigure(basis, a.Unit, values, total) + { + MeterIds = meterIds, + InputUnits = [a.Unit], + ComparisonValues = comparisonValues, + ComparisonTotal = comparisonTotal, + Change = change, + }; + } + + /// + /// The change of a derived figure against its comparison (D-07 at whole buckets): between the two totals when both are + /// complete, otherwise between the sums of the paired buckets that are complete on both sides; unavailable when none + /// is — never a change between two different stretches of time. + /// + internal static Change MatchedChange( + BucketValue total, IReadOnlyList values, BucketValue? comparisonTotal, IReadOnlyList? comparisonValues) + { + if (comparisonTotal is null || comparisonValues is null) + { + return Change.Unavailable; + } + + if (total.Status == BucketStatus.Available && comparisonTotal.Status == BucketStatus.Available) + { + return Change.Between(total.Value, comparisonTotal.Value); + } + + var matched = values.Zip(comparisonValues) + .Where(p => p.First is { Status: BucketStatus.Available, Value: not null } && p.Second is { Status: BucketStatus.Available, Value: not null }) + .ToList(); + return matched.Count == 0 + ? Change.Unavailable + : Change.Between(matched.Sum(p => p.First.Value!.Value), matched.Sum(p => p.Second.Value!.Value)); + } + + private static BucketValue SumOfKnown(IReadOnlyList buckets, BucketValue? unresolved) + { + var known = buckets.Where(v => v.Value is not null).ToList(); + if (known.Count == 0) + { + return unresolved is not null ? Spoiled(unresolved) : BucketValue.Missing(buckets.Any(b => b.Issue == ValueIssue.MissingSource) ? ValueIssue.MissingSource : ValueIssue.NoCoverage); + } + + var sum = known.Sum(v => v.Value!.Value); + var provenance = known.Aggregate(Provenance.None, (all, v) => all | v.Provenance); + return known.Count == buckets.Count && known.TrueForAll(v => v.Status == BucketStatus.Available) + ? new BucketValue(sum, BucketStatus.Available, provenance) + : new BucketValue(sum, BucketStatus.Partial, provenance, ValueIssue.PartialCoverage); + } + + private static BucketValue Spoiled(BucketValue source) => + new(null, source.Status, Provenance.None, Issue(source, source.Status switch + { + BucketStatus.Pending => ValueIssue.AnalysisPending, + BucketStatus.Invalid => ValueIssue.InvalidDefinition, + BucketStatus.Unresolved => ValueIssue.CoarseResolution, + _ => ValueIssue.NoCoverage, + }), source.IssueDetail, source.DependencyPath); + + private static ValueIssue Issue(BucketValue value, ValueIssue fallback) => value.Issue == ValueIssue.None ? fallback : value.Issue; + + /// True when a meter is in service on some day of [firstDay, endDay) (D-24). + private static bool InService(Meter meter, DateOnly firstDay, DateOnly endDay) => + (meter.InstalledAt is not { } installed || installed < endDay) && (meter.RetiredAt is not { } retired || retired >= firstDay); + + /// + /// A part of a bucket as a bucket the reader sums (as the bill reads its parts): its local days, clipped to the + /// bucket's instants, with the bucket's size so it resolves as the bucket would (D-14). + /// + private static AnalysisBucket PartBucket(CostPart part, AnalysisBucket bucket, TimeZoneInfo zone) + { + var start = GapAttribution.LocalMidnight(part.FirstDay, zone); + var end = GapAttribution.LocalMidnight(part.EndDay, zone); + var from = start > bucket.From ? start : bucket.From; + var to = end < bucket.To ? end : bucket.To; + return new AnalysisBucket(part.FirstDay, part.EndDay, from, to < from ? from : to, bucket.Size); + } + + /// True when two part lists are the same days, read at sizes that resolve them alike. + private static bool SameParts(IReadOnlyList a, BucketSize aSize, IReadOnlyList b, BucketSize bSize) + { + static bool Monthly(BucketSize size) => size is BucketSize.Month or BucketSize.Year; + + return a.Count == b.Count + && (aSize == bSize || (Monthly(aSize) && Monthly(bSize))) + && a.Zip(b).All(p => p.First.FirstDay == p.Second.FirstDay && p.First.EndDay == p.Second.EndDay); + } + + private static CostQuantity QuantityOf(CostPart part, BucketValue value) => + value.Value is { } amount && value.Status is BucketStatus.Available or BucketStatus.Partial + ? CostQuantity.Known(part, amount, value.Status) + : CostQuantity.Unknown(part, value.Status); + + // ------------------------------------------------------------------------------------------------ one section + + /// One energy type's section: its read, its role inputs, the derived figures and their value. + private sealed class SiteRun( + SolarService service, + MeterVaultDbContext db, + AnalysisCatalog catalog, + TariffBook book, + SolarRequest request, + int typeId, + string typeName) + { + private readonly List _attention = []; + + private ResolvedPeriod Period => request.Period; + + public async Task ExecuteAsync(BucketPlan? plan, CancellationToken cancellationToken) + { + var roles = Roles(); + var read = new AnalysisRequest(AnalysisScope.ForEnergyType(typeId), Period) + { + Bucket = request.Bucket, + Plan = plan, + Comparison = request.Comparison, + IncludeMeterSeries = true, + }; + var result = await service._reader.ReadAsync(db, catalog, read, cancellationToken).ConfigureAwait(false); + var site = new SolarSite(typeId, typeName, result) { Roles = roles }; + if (result.Refusal != AnalysisRefusal.None) + { + return site; + } + + var inputs = Inputs(result, roles, withComparison: true); + var derived = Derived(inputs); + + var generationMeters = catalog.Meters.Values.Where(m => m.EnergyTypeId == typeId && IsGenerationMeter(m)).ToList(); + var relevant = new HashSet(inputs.GenerationMembers + .Concat(roles.SelectMany(r => r.Holders.Select(h => h.MeterId))) + .Concat(generationMeters.Select(m => m.Id))); + var meters = generationMeters + .Select(m => (Meter: m, Series: result.SeriesFor(m.Id))) + .Select(p => new SolarMeter( + p.Meter.Id, + p.Meter.Name, + p.Meter.IsVirtual, + inputs.GenerationMembers.Contains(p.Meter.Id), + p.Series?.Unit ?? p.Meter.Quantity.Unit, + p.Series?.Total ?? BucketValue.Missing())) + .OrderByDescending(m => m.IsCounted) + .ThenByDescending(m => m.Total.Value ?? double.MinValue) + .ThenBy(m => m.Name, StringComparer.CurrentCulture) + .ToList(); + + var savings = derived.Self is { } self ? await SavingsAsync(result, roles, self, cancellationToken).ConfigureAwait(false) : null; + var credit = await FeedInCreditAsync(result.Plan, roles, cancellationToken).ConfigureAwait(false); + + return site with + { + Unit = inputs.Unit, + Generation = inputs.GenerationSeries, + OtherGeneration = inputs.OtherGeneration, + SelfConsumption = derived.Self, + FeedIn = derived.FeedIn, + SiteUse = derived.Use, + GridImport = inputs.Import, + Autarky = derived.Self is { } s && derived.Use is { } u ? Share(s, u) : null, + SelfConsumptionShare = derived.Self is { } part && inputs.Generation is { } whole ? Share(part, whole) : null, + Savings = savings, + FeedInCredit = credit, + Meters = meters, + Problems = [.. result.Problems.Where(p => p.MeterId is null || relevant.Contains(p.MeterId.Value) || p.MeterIds.Any(relevant.Contains))], + CostAttention = _attention, + }; + } + + /// Total consumption, grid import and grid export: who holds each (effective roles, A-07) and who could. + private List Roles() + { + var meters = catalog.Meters.Values.Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Id).ToList(); + var roles = new List(); + foreach (var role in MeterRoleRules.All) + { + List holders = + [ + .. meters.Where(m => m.Role == role) + .OrderBy(m => m.Meter.RetiredAt is null ? 0 : 1) + .ThenBy(m => m.Id) + .Select(m => new SolarMeterRef(m.Id, m.Name)), + ]; + List candidates = holders.Count > 0 + ? [] + : [ + .. meters.Where(m => !m.IsVirtual && m.Meter.RetiredAt is null && m.Role is null && MeterRoleRules.IsAllowed(role, m.Meter.Mode)) + .OrderBy(m => m.Name, StringComparer.CurrentCulture) + .Select(m => new SolarMeterRef(m.Id, m.Name)), + ]; + roles.Add(new SolarRoleStatus(role, holders, candidates)); + } + + return roles; + } + + /// The generation measure and the role meters' figures of one read. + private Inputs Inputs(AnalysisResult result, IReadOnlyList roles, bool withComparison) + { + var load = RoleFigure(result, roles, MeterRole.TotalLoad, withComparison); + var import = RoleFigure(result, roles, MeterRole.GridImport, withComparison); + var export = RoleFigure(result, roles, MeterRole.GridExport, withComparison); + + var measures = result.Measures.Where(s => s.EnergyTypeId == typeId && s.Key.Measure == TotalsMeasure.Generation).ToList(); + var preferred = load?.Unit ?? import?.Unit ?? export?.Unit; + var generation = measures.FirstOrDefault(m => preferred is not null && Units.AreSame(m.Unit, preferred)) ?? measures.FirstOrDefault(); + var figure = generation is null ? null : Measured(generation, withComparison); + + return new Inputs( + generation?.Unit ?? preferred, + generation, + [.. measures.Where(m => !ReferenceEquals(m, generation))], + [.. measures.SelectMany(m => m.MemberIds)], + figure, + load, + import, + export); + } + + /// + /// A role's figure from its holders' own series: one holder as it is; several (a meter and its successor) bucket by + /// bucket from those in service (D-24). Null when nobody holds the role. + /// + private SolarFigure? RoleFigure(AnalysisResult result, IReadOnlyList roles, MeterRole role, bool withComparison) + { + var holders = roles.First(r => r.Role == role).Holders + .Select(h => result.SeriesFor(h.MeterId)) + .OfType() + .ToList(); + if (holders.Count == 0) + { + return null; + } + + if (holders.Count == 1) + { + return Measured(holders[0], withComparison); + } + + var unit = holders[0].Unit; + var same = holders.Where(s => Units.AreSame(s.Unit, unit)).ToList(); + var buckets = result.Plan.Buckets; + List values = [.. buckets.Select((bucket, i) => SumInService(same, s => s.Values[i], bucket.FirstDay, bucket.EndDay))]; + var total = SumInService(same, s => s.Total, Period.FirstDay, Period.EffectiveLastDay().AddDays(1)); + + IReadOnlyList? comparisonValues = null; + BucketValue? comparisonTotal = null; + if (withComparison && result.Comparison is { Period: { } comparison, Buckets: var pairs } && same.TrueForAll(s => s.Comparison is not null)) + { + comparisonValues = [.. pairs.Select((pair, i) => SumInService(same, s => s.Comparison!.Values[i], pair.Comparison.FirstDay, pair.Comparison.EndDay))]; + comparisonTotal = SumInService(same, s => s.Comparison!.Total, comparison.FirstDay, comparison.EffectiveLastDay().AddDays(1)); + } + + return new SolarFigure(SolarBasis.Measured, unit, values, total) + { + InputUnits = [unit], + MeterIds = [.. same.Select(s => s.MeterId!.Value).Order()], + ComparisonValues = comparisonValues, + ComparisonTotal = comparisonTotal, + Change = MatchedChange(total, values, comparisonTotal, comparisonValues), + }; + } + + private BucketValue SumInService(IReadOnlyList series, Func value, DateOnly firstDay, DateOnly endDay) + { + List<(int, BucketValue)> members = + [ + .. series.Where(s => catalog.Find(s.MeterId!.Value) is { } meter && InService(meter.Meter, firstDay, endDay)) + .Select(s => (s.MeterId!.Value, value(s))), + ]; + return MeasureValues.Sum(members); + } + + private static SolarFigure Measured(AnalysisSeries series, bool withComparison) => + new(SolarBasis.Measured, series.Unit, series.Values, series.Total) + { + InputUnits = [series.Unit], + MeterIds = series.MeterId is { } id ? [id] : [.. series.MemberIds.Order()], + ComparisonValues = withComparison ? series.Comparison?.Values : null, + ComparisonTotal = withComparison ? series.Comparison?.Total : null, + Change = withComparison ? series.Comparison?.Change ?? Change.Unavailable : Change.Unavailable, + }; + + /// Self-consumption, feed-in and site use as far as the roles allow. + private static (SolarFigure? Self, SolarFigure? FeedIn, SolarFigure? Use) Derived(Inputs inputs) + { + SolarFigure? self = null; + if (inputs.Load is { } load && inputs.Import is { } import) + { + self = Derive(SolarBasis.LoadMinusImport, load, import, -1); + } + else if (inputs.Generation is { } generation && inputs.Export is { } exported) + { + self = Derive(SolarBasis.GenerationMinusExport, generation, exported, -1); + } + + var feedIn = inputs.Export + ?? (self is { Basis: SolarBasis.LoadMinusImport } && inputs.Generation is { } g + ? Derive(SolarBasis.GenerationMinusSelfConsumption, g, self, -1) + : null); + + var use = inputs.Load + ?? (self is { Basis: SolarBasis.GenerationMinusExport } && inputs.Import is { } bought + ? Derive(SolarBasis.SelfConsumptionPlusImport, self, bought, 1) + : null); + + return (self, feedIn, use); + } + + /// + /// Self-consumption at the unit price of the grid import meter (or, without one, of the total consumption meter): + /// per bucket from the buckets' local months, and over the period from its own months (D-36). + /// + private async Task SavingsAsync(AnalysisResult result, IReadOnlyList roles, SolarFigure self, CancellationToken cancellationToken) + { + var priceMeter = roles.First(r => r.Role == MeterRole.GridImport).Holders.FirstOrDefault() + ?? roles.First(r => r.Role == MeterRole.TotalLoad).Holders.FirstOrDefault(); + if (priceMeter is null || result.Plan.Buckets.Count == 0 || self.UnitsDiffer) + { + return null; + } + + var plan = result.Plan; + var zone = service._reader.Zone; + var displayParts = CostCalculator.Parts(plan.Buckets); + var displayValues = displayParts.Count == plan.Buckets.Count + ? self.Values + : await SelfOfPartsAsync(roles, displayParts, plan.Buckets, plan.Size, zone, cancellationToken).ConfigureAwait(false); + + var periodBucket = PeriodBucket.Of(Period); + var periodParts = CostCalculator.Parts([periodBucket]); + var periodValues = SameParts(displayParts, plan.Size, periodParts, periodBucket.Size) + ? displayValues + : await SelfOfPartsAsync(roles, periodParts, [periodBucket], periodBucket.Size, zone, cancellationToken).ConfigureAwait(false); + if (displayValues is null || periodValues is null) + { + return null; + } + + CostLine Line(IReadOnlyList parts, IReadOnlyList values) => + new(priceMeter.MeterId, typeId, BillLineKind.UnitPrice, self.Unit, [.. parts.Select((p, i) => QuantityOf(p, values[i]))]); + + var today = PeriodResolver.LocalDate(Period.Now, zone); + var byBucket = CostCalculator.Calculate(new CostRequest(plan.Buckets, today, book, [Line(displayParts, displayValues)])); + var overPeriod = CostCalculator.Calculate(new CostRequest([periodBucket], today, book, [Line(periodParts, periodValues)])); + foreach (var missing in overPeriod.Total.MissingPrices) + { + _attention.Add(new CostAttention(CostAttentionKind.MissingPrice, priceMeter.MeterId) { Price = missing }); + } + + return new SolarMoney(byBucket.Lines[0].Buckets, overPeriod.Lines[0].Total, [priceMeter.MeterId]); + } + + /// Self-consumption per part (local month pieces of the buckets), read again for those parts. + private async Task?> SelfOfPartsAsync( + IReadOnlyList roles, + IReadOnlyList parts, + IReadOnlyList buckets, + BucketSize size, + TimeZoneInfo zone, + CancellationToken cancellationToken) + { + List partBuckets = [.. parts.Select(p => PartBucket(p, buckets[p.BucketIndex], zone))]; + var read = new AnalysisRequest(AnalysisScope.ForEnergyType(typeId), Period) + { + Plan = new BucketPlan(size, size, partBuckets, partBuckets.Count, Refused: false, Suggested: null), + IncludeMeterSeries = true, + QuantitiesOnly = true, + }; + var result = await service._reader.ReadAsync(db, catalog, read, cancellationToken).ConfigureAwait(false); + return result.Refusal == AnalysisRefusal.None ? Derived(Inputs(result, roles, withComparison: false)).Self?.Values : null; + } + + /// The feed-in credit of the grid export meter(s): the cost reader's feed-in line, as the bill credits it (D-34). + private async Task FeedInCreditAsync(BucketPlan plan, IReadOnlyList roles, CancellationToken cancellationToken) + { + var exporters = roles.First(r => r.Role == MeterRole.GridExport).Holders; + if (exporters.Count == 0 || plan.Buckets.Count == 0) + { + return null; + } + + var lines = new List(); + foreach (var exporter in exporters) + { + var costs = await service._costs + .ReadAsync(new CostAnalysisRequest(CostScope.ForMeter(exporter.MeterId), Period) { Plan = plan }, cancellationToken) + .ConfigureAwait(false); + lines.AddRange(costs.Lines.Where(l => l.Kind == BillLineKind.FeedIn)); + _attention.AddRange(costs.Attention.Where(a => a.Kind == CostAttentionKind.MissingPrice && a.Price?.Component == TariffComponent.FeedIn)); + } + + if (lines.Count == 0) + { + return null; + } + + List buckets = [.. plan.Buckets.Select((_, i) => CostAmount.Sum(lines.Select(l => l.Buckets[i])))]; + return new SolarMoney(buckets, CostAmount.Sum(lines.Select(l => l.Total)), [.. lines.Select(l => l.MeterId).Distinct().Order()]); + } + } + + /// What one read gives the derivations. + private sealed record Inputs( + string? Unit, + AnalysisSeries? GenerationSeries, + IReadOnlyList OtherGeneration, + IReadOnlyList GenerationMembers, + SolarFigure? Generation, + SolarFigure? Load, + SolarFigure? Import, + SolarFigure? Export); } diff --git a/src/Infrastructure/Dashboard/TankLevels.cs b/src/Infrastructure/Dashboard/TankLevels.cs new file mode 100644 index 0000000..4f83904 --- /dev/null +++ b/src/Infrastructure/Dashboard/TankLevels.cs @@ -0,0 +1,118 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; + +namespace MeterVault.Infrastructure.Dashboard; + +/// +/// A tank's contents and forecast from its level and delivery events (SDD §7.3, D-54) — pure, so the view's "now", its +/// period end and its projection follow one rule, the one the normalizer books draws by: at the same instant a delivery +/// comes before the level, so a dipstick taken right after a refill already includes it. +/// +internal static class TankLevels +{ + /// + /// The contents at : the last dipstick before it (at it, when ) plus + /// the deliveries after that dipstick up to the same bound; null without a dipstick by then. + /// + /// The tank's level and delivery events, any order. + /// The instant. + /// True to count events at exactly (now); false for a period's exclusive end. + /// The tank's cm → volume curve, if any. + public static TankContents? ContentsAt(IReadOnlyList events, DateTimeOffset at, bool inclusive, CalibrationCurve? calibration) + { + ArgumentNullException.ThrowIfNull(events); + + bool Upto(MeterEvent e) => inclusive ? e.Time <= at : e.Time < at; + + var level = events + .Where(e => e.EventType == MeterEventType.TankLevel && Upto(e)) + .OrderBy(e => e.Time) + .ThenBy(e => e.Id) + .LastOrDefault(); + if (level is null) + { + return null; + } + + var dipstick = Dipstick(level, calibration); + var since = events.Where(e => e.EventType == MeterEventType.Delivery && e.Time > level.Time && Upto(e)).ToList(); + var delivered = since.Sum(e => e.Amount ?? 0); + return new TankContents(at, dipstick.Volume + delivered, dipstick, delivered, since.Count); + } + + /// A level event as a dipstick: a cm reading through the calibration, anything else as a volume. + public static TankDipstick Dipstick(MeterEvent level, CalibrationCurve? calibration) + { + ArgumentNullException.ThrowIfNull(level); + + var reading = level.Amount ?? 0; + var centimetres = string.Equals(level.Unit, "cm", StringComparison.OrdinalIgnoreCase); + var calibrated = centimetres && calibration is not null; + return new TankDipstick(level.Time, calibrated ? calibration!.ToVolume(reading) : reading, reading, level.Unit) { IsCalibrated = calibrated }; + } + + /// + /// The forecast to empty as of (D-54, D-09): a straight line through the draw between the last + /// dipstick and the earliest one of the year before it (deliveries in between added back), projected from the last + /// dipstick's contents plus the deliveries since. Suppressed when the last dipstick is older than + /// days, or the dipsticks span less than + /// . + /// + public static TankForecast Forecast(IReadOnlyList events, DateTimeOffset now, TimeZoneInfo zone, CalibrationCurve? calibration) + { + ArgumentNullException.ThrowIfNull(events); + ArgumentNullException.ThrowIfNull(zone); + + var levels = events + .Where(e => e.EventType == MeterEventType.TankLevel && e.Time <= now) + .OrderBy(e => e.Time) + .ThenBy(e => e.Id) + .ToList(); + if (levels.Count == 0) + { + return new TankForecast(TankForecastState.NoDipstick); + } + + var anchor = levels[^1]; + var age = (int)Math.Floor((now - anchor.Time).TotalDays); + if (age > TankForecast.MaxDipstickAgeDays) + { + return new TankForecast(TankForecastState.DipstickTooOld) { DipstickAgeDays = age }; + } + + var windowStart = anchor.Time.AddDays(-TankForecast.WindowDays); + var first = levels.FirstOrDefault(l => l.Time >= windowStart && l.Time < anchor.Time); + var span = first is null ? 0 : (anchor.Time - first.Time).TotalDays; + if (first is null || span < TankForecast.MinBasisDays) + { + return new TankForecast(TankForecastState.NotEnoughHistory) { DipstickAgeDays = age }; + } + + var basisDays = (int)Math.Round(span); + var deliveredBetween = events + .Where(e => e.EventType == MeterEventType.Delivery && e.Time > first.Time && e.Time <= anchor.Time) + .Sum(e => e.Amount ?? 0); + var draw = Dipstick(first, calibration).Volume + deliveredBetween - Dipstick(anchor, calibration).Volume; + if (draw <= 0 || !double.IsFinite(draw)) + { + return new TankForecast(TankForecastState.NoUse) { DipstickAgeDays = age, BasisDays = basisDays }; + } + + var perDay = draw / span; + var contents = ContentsAt(events, now, inclusive: true, calibration)!.Volume; + var daysToEmpty = Math.Max(0, contents) / perDay; + + // Guard against absurd horizons (a near-zero draw) that would overflow a date. + DateOnly? emptyOn = daysToEmpty < 365 * 100 + ? PeriodResolver.LocalDate(anchor.Time.AddDays(daysToEmpty), zone) + : null; + return new TankForecast(TankForecastState.Projected) + { + EmptyOn = emptyOn, + PerDay = perDay, + BasisDays = basisDays, + DipstickAgeDays = age, + }; + } +} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index f97fad6..dfc95a6 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -5,6 +5,7 @@ using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace MeterVault.Infrastructure; @@ -26,6 +27,9 @@ public static class DependencyInjection services.AddScoped(sp => sp.GetRequiredService>().CreateDbContext()); + // The one clock (D-01): pages and endpoints read "now" once per request through it, and the few + // services that stamp or judge time take it; tests replace it with a fixed one. + services.TryAddSingleton(TimeProvider.System); services.AddSingleton(_ => NormalizationEngine.CreateDefault()); services.AddScoped(); services.AddScoped(); @@ -48,11 +52,14 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Infrastructure/Import/ReferenceDataImporter.cs b/src/Infrastructure/Import/ReferenceDataImporter.cs index a748ae7..d29a2eb 100644 --- a/src/Infrastructure/Import/ReferenceDataImporter.cs +++ b/src/Infrastructure/Import/ReferenceDataImporter.cs @@ -1,3 +1,5 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Persistence; @@ -51,7 +53,7 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820); var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L"); var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h"); - // A virtual "sum" meter: no readings of its own — in the flow view it equals Solar 1 + Solar 2. + // A virtual "sum" meter: no readings of its own; its explicit definition (below) makes it Solar 1 + Solar 2. var sumSolar = Meter("Summe Solar", electricity, MeterMode.Virtual, "kWh"); // Tag the PV meters' roles (config, not hardcoded names) so the Solar panel can derive @@ -62,6 +64,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService _db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner, sumSolar); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Summe Solar's calculation is written down (D-28), not implied by its links: a generation sum in kWh, not costed + // (generation is never billed, A-15). The links below stay as flow topology only. + sumSolar.Meta = VirtualDefinitionJson.Write(sumSolar.Meta, SummeSolarDefinition(solar1.Id, solar2.Id)); + _db.Tanks.Add(new Tank { MeterId = oilTank.Id, @@ -82,6 +88,9 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService AddElectricityTariffs(electricity); AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1)); + // The Wasser sheet's price rises to 7,00 €/m³ in January 2026 (D-44): without it the seeded 2026 water bill + // would be 79 m³ × 5 € = 395 € against the sheet's 553 €. + AddTariff(TariffScope.EnergyType, water, 7.00, "EUR/m3", new DateOnly(2026, 1, 1)); // Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the // Kosten sheet (oil has no tariff). Linking a meter AND importing its Kosten column into the // same category would double-count, so we keep exactly one cost source per category. @@ -97,6 +106,17 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService await ImportSheetAsync(sampleDataDirectory, CostsFile, HeizungCostsProfile(categoryIds.Heizung), cancellationToken).ConfigureAwait(false); } + /// + /// The seeded "Summe Solar": m<Solar 1> + m<Solar 2>, generation in kWh, not costed — generation is + /// never billed, so its sources have no metered cost to add (A-15) — the definition the startup upgrade would derive + /// from its two incoming links (D-28). + /// + public static VirtualDefinition SummeSolarDefinition(int solar1Id, int solar2Id) + { + var formula = Formula.Sum([solar1Id, solar2Id]); + return new(formula.ToString(), QuantityKind.Generation, "kWh", VirtualValidator.DefaultCostRule(formula, QuantityKind.Generation)); + } + /// Kosten profile that imports only the Heizung column — Strom/Wasser are metered. private static MappingProfile HeizungCostsProfile(int heizungCategoryId) => new() { diff --git a/src/Infrastructure/Normalization/AnalysisDataWriter.cs b/src/Infrastructure/Normalization/AnalysisDataWriter.cs new file mode 100644 index 0000000..b12b047 --- /dev/null +++ b/src/Infrastructure/Normalization/AnalysisDataWriter.cs @@ -0,0 +1,213 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Rollups; +using MeterVault.Infrastructure.Persistence; +using MeterVault.Infrastructure.Persistence.Analysis; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace MeterVault.Infrastructure.Normalization; + +/// +/// Writes one meter's analysis tables (D-12) — day and month rollups, coverage runs and rollup state — by diff +/// against what is stored: a row that is still right is not touched, a changed one is updated in place, a new +/// one added and a vanished one removed. Stages the changes; the caller's SaveChanges writes them in its +/// transaction, together with the meter's consumption. +/// +/// +/// +/// A recompute runs on every live reading, and almost all of a meter's history comes out of it unchanged. A +/// diff keeps that to the rows the new reading actually moved — usually the current day and month and the +/// last coverage run — instead of rewriting years of rows each time. +/// +/// +/// The change tracker, not only the database, is the truth here: one context may recompute a meter several +/// times before it saves (an import touching a meter twice, a worker ingesting two readings in one scope). +/// Rows staged by an earlier pass are still tracked — added, modified or deleted — and the diff builds on +/// them rather than tripping over their keys. +/// +/// +internal sealed class AnalysisDataWriter(MeterVaultDbContext db) +{ + /// Every analysis table is keyed by meter first, under this property. + private const string MeterIdProperty = nameof(MeterRollupState.MeterId); + + private readonly MeterVaultDbContext _db = db; + + /// + /// Stages the meter's rollups and coverage, and its state row. moves to + /// only when something about the meter's analysis data changed. + /// + /// True when any row was added, changed or removed. + public async Task WriteAsync( + int meterId, + MeterRollups rollups, + IReadOnlyList coverage, + MeterRollupState state, + DateTimeOffset now, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(rollups); + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(state); + + // Listing tracked entries detects changes across the whole context by default — every consumption row + // the recompute just staged, several times over. Nothing here depends on it: the diff compares values + // itself, and SaveChanges detects the edits it makes. + var autoDetect = _db.ChangeTracker.AutoDetectChangesEnabled; + _db.ChangeTracker.AutoDetectChangesEnabled = false; + try + { + return await WriteCoreAsync(meterId, rollups, coverage, state, now, cancellationToken).ConfigureAwait(false); + } + finally + { + _db.ChangeTracker.AutoDetectChangesEnabled = autoDetect; + } + } + + private async Task WriteCoreAsync( + int meterId, + MeterRollups rollups, + IReadOnlyList coverage, + MeterRollupState state, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var changed = false; + + var days = await CurrentAsync(_db.ConsumptionRollups, meterId, cancellationToken).ConfigureAwait(false); + changed |= Sync( + days, rollups.Days, d => (d.Day, d.Kind), b => (b.Start, b.Kind), + (row, bucket) => row.Assign(bucket), bucket => ConsumptionRollup.From(meterId, bucket)); + + var months = await CurrentAsync(_db.ConsumptionRollupMonths, meterId, cancellationToken).ConfigureAwait(false); + changed |= Sync( + months, rollups.Months, m => (m.Month, m.Kind), b => (b.Start, b.Kind), + (row, bucket) => row.Assign(bucket), bucket => ConsumptionRollupMonth.From(meterId, bucket)); + + var runs = await CurrentAsync(_db.MeterCoverage, meterId, cancellationToken).ConfigureAwait(false); + changed |= Sync( + runs, coverage, r => r.SpanFrom, r => r.From.ToUniversalTime(), + (row, run) => row.Assign(run), run => MeterCoverageRun.From(meterId, run)); + + var states = await CurrentAsync(_db.MeterRollupStates, meterId, cancellationToken).ConfigureAwait(false); + var entry = states.SingleOrDefault(); + if (entry is null) + { + _db.MeterRollupStates.Add(new MeterRollupState + { + MeterId = meterId, + Revision = state.Revision, + Zone = state.Zone, + NormalizedUnit = state.NormalizedUnit, + Kind = state.Kind, + BuiltAt = now.ToUniversalTime(), + }); + return true; + } + + var stored = entry.Entity; + var resurrected = entry.State == EntityState.Deleted; + if (resurrected) + { + entry.State = EntityState.Modified; + } + + var stateChanged = resurrected + || stored.Revision != state.Revision + || !string.Equals(stored.Zone, state.Zone, StringComparison.Ordinal) + || !string.Equals(stored.NormalizedUnit, state.NormalizedUnit, StringComparison.Ordinal) + || stored.Kind != state.Kind; + if (changed || stateChanged) + { + stored.Revision = state.Revision; + stored.Zone = state.Zone; + stored.NormalizedUnit = state.NormalizedUnit; + stored.Kind = state.Kind; + stored.BuiltAt = now.ToUniversalTime(); + } + + return changed || stateChanged; + } + + /// + /// The meter's rows as they stand for this context: the stored rows (tracked, so identity resolution hands + /// back anything an earlier pass already holds) plus rows an earlier pass added and has not saved, minus the + /// ones it removed, which keep their entry (state Deleted) so they can be brought back. + /// + /// + /// A tracked row the database no longer has — removed behind the tracker's back, as ExecuteDelete or a + /// cascade does — is detached, as the consumption rows are: updating or deleting it would fail the save. + /// + private async Task>> CurrentAsync(DbSet set, int meterId, CancellationToken cancellationToken) + where T : class + { + var stored = await set.Where(e => EF.Property(e, MeterIdProperty) == meterId) + .ToListAsync(cancellationToken).ConfigureAwait(false); + var inDatabase = new HashSet(stored, ReferenceEqualityComparer.Instance); + + var entries = _db.ChangeTracker.Entries() + .Where(e => e.State != EntityState.Detached && (int)e.Property(MeterIdProperty).CurrentValue! == meterId) + .ToList(); + foreach (var stale in entries.Where(e => e.State != EntityState.Added && !inDatabase.Contains(e.Entity))) + { + stale.State = EntityState.Detached; + } + + return [.. entries.Where(e => e.State != EntityState.Detached)]; + } + + /// Reconciles the tracked rows of one table with the desired values; true when anything changed. + private bool Sync( + List> current, + IEnumerable desired, + Func entityKey, + Func desiredKey, + Func assign, + Func create) + where TEntity : class + where TKey : notnull + { + var changed = false; + var byKey = current.ToDictionary(e => entityKey(e.Entity)); + var wanted = new HashSet(); + + foreach (var item in desired) + { + var key = desiredKey(item); + wanted.Add(key); + if (!byKey.TryGetValue(key, out var entry)) + { + _db.Add(create(item)); + changed = true; + continue; + } + + if (entry.State == EntityState.Deleted) + { + // Removed by an earlier pass that has not been saved: the row is wanted again, so it stays + // and is updated with whatever it holds now. + assign(entry.Entity, item); + entry.State = EntityState.Modified; + changed = true; + continue; + } + + changed |= assign(entry.Entity, item); + } + + foreach (var (key, entry) in byKey) + { + if (wanted.Contains(key) || entry.State == EntityState.Deleted) + { + continue; + } + + // An added row is simply dropped from tracking; a stored one is deleted. + _db.Remove(entry.Entity); + changed = true; + } + + return changed; + } +} diff --git a/src/Infrastructure/Normalization/MeterConfigFactory.cs b/src/Infrastructure/Normalization/MeterConfigFactory.cs index 3e7e65e..fad43b1 100644 --- a/src/Infrastructure/Normalization/MeterConfigFactory.cs +++ b/src/Infrastructure/Normalization/MeterConfigFactory.cs @@ -20,6 +20,7 @@ public static class MeterConfigFactory Mode = meter.Mode, Unit = meter.Unit, InitialBaseline = meter.InitialBaseline, + InstalledAt = meter.InstalledAt, Tank = tank is null ? null : new TankConfig { Capacity = tank.Capacity, diff --git a/src/Infrastructure/Normalization/NormalizationService.cs b/src/Infrastructure/Normalization/NormalizationService.cs index 812b7fd..46577e4 100644 --- a/src/Infrastructure/Normalization/NormalizationService.cs +++ b/src/Infrastructure/Normalization/NormalizationService.cs @@ -1,7 +1,12 @@ +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Rollups; +using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; +using MeterVault.Infrastructure.Persistence.Analysis; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -10,50 +15,66 @@ namespace MeterVault.Infrastructure.Normalization; /// /// Bridges persisted readings/events to the pure engine: loads a meter's inputs, recomputes its /// consumption wholesale (consumption is a pure function of readings + events), and replaces the -/// stored rows. Virtual meters are skipped here — they are computed on read (SDD §14.1). +/// stored rows — together with the analysis data derived from the same rows (D-12): local day and +/// month rollups, coverage runs and the meter's rollup state. Virtual meters store none of it — they +/// are computed on read (SDD §14.1, D-27) — so their derived rows are purged and only their state is +/// written. /// /// /// Without options (tests, or a hand-built instance) months are UTC months; the application always /// passes the configured instance timezone, so stored consumption files under the months the charts show. /// public sealed class NormalizationService( - MeterVaultDbContext db, INormalizationEngine engine, IOptions? options = null) + MeterVaultDbContext db, + INormalizationEngine engine, + IOptions? options = null, + TimeProvider? time = null) { private readonly MeterVaultDbContext _db = db; private readonly INormalizationEngine _engine = engine; private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve(options?.Value.TimeZone); + private readonly TimeProvider _time = time ?? TimeProvider.System; /// The zone months are divided in: the configured one, or UTC when it is missing or unknown. public TimeZoneInfo TimeZone => _zone; /// /// Recomputes and replaces the consumption series for one meter from all its current readings - /// and events. Tags new rows with for provenance. Does not save. + /// and events, and brings its rollups, coverage and rollup state in line with it (by diff). Tags + /// new consumption rows with for provenance. Does not save: the caller's + /// SaveChanges writes everything in its transaction. /// public async Task RecomputeMeterAsync(int meterId, int? batchId, CancellationToken cancellationToken = default) { var meter = await _db.Meters.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); - if (meter is null || meter.Mode == MeterMode.Virtual) + if (meter is null) { return; } - var tank = await _db.Tanks.FirstOrDefaultAsync(t => t.MeterId == meterId, cancellationToken).ConfigureAwait(false); - var config = MeterConfigFactory.FromMeter(meter, tank); + Tank? tank = null; + IReadOnlyList consumption = []; + if (meter.Mode != MeterMode.Virtual) + { + tank = await _db.Tanks.FirstOrDefaultAsync(t => t.MeterId == meterId, cancellationToken).ConfigureAwait(false); + var config = MeterConfigFactory.FromMeter(meter, tank); - var readings = await _db.Readings - .Where(r => r.MeterId == meterId) - .OrderBy(r => r.Time) - .ToListAsync(cancellationToken).ConfigureAwait(false); + var readings = await _db.Readings + .Where(r => r.MeterId == meterId) + .OrderBy(r => r.Time) + .ToListAsync(cancellationToken).ConfigureAwait(false); - var events = await _db.MeterEvents - .Where(e => e.MeterId == meterId) - .OrderBy(e => e.Time) - .ToListAsync(cancellationToken).ConfigureAwait(false); + var events = await _db.MeterEvents + .Where(e => e.MeterId == meterId) + .OrderBy(e => e.Time) + .ToListAsync(cancellationToken).ConfigureAwait(false); - var context = new NormalizationContext { Meter = config, Readings = readings, Events = events, TimeZone = _zone }; - var consumption = _engine.Normalize(context); + var context = new NormalizationContext { Meter = config, Readings = readings, Events = events, TimeZone = _zone }; + consumption = _engine.Normalize(context); + } + // A virtual meter lands here with nothing to store: whatever an earlier mode left behind is purged, so + // a meter switched to virtual does not keep a stale physical series next to its calculation. await _db.Consumption.Where(c => c.MeterId == meterId) .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); @@ -73,5 +94,40 @@ public sealed class NormalizationService( row.ImportBatchId = batchId; _db.Consumption.Add(row); } + + // Derived from the same in-memory rows, which still carry their source intervals (D-10) — a row read + // back from the database would not. + var rollups = RollupBuilder.Build(consumption, _zone); + var coverage = CoverageBuilder.Build(consumption, _zone); + var quantity = QuantityOf(meter, tank); + var state = new MeterRollupState + { + MeterId = meterId, + Revision = NormalizationUpgrade.CurrentRevision, + Zone = _zone.Id, + NormalizedUnit = quantity.Unit, + Kind = quantity.Kind, + }; + + await new AnalysisDataWriter(_db) + .WriteAsync(meterId, rollups, coverage, state, _time.GetUtcNow(), cancellationToken) + .ConfigureAwait(false); + } + + /// + /// What the meter's amounts measure and in which unit (D-20). A virtual meter is what its stored definition + /// declares; without a readable one (a legacy meter before D-28 converts it, or a malformed blob) the + /// quantity falls back to consumption in the meter's own unit, flagged as undeclared. + /// + private static NormalizedQuantity QuantityOf(Meter meter, Tank? tank) + { + if (meter.Mode != MeterMode.Virtual) + { + return NormalizedQuantity.Of(meter, tank); + } + + var read = VirtualDefinitionJson.Read(meter.Meta); + var declared = read.Status == VirtualDefinitionReadStatus.Present ? read.Definition?.DeclaredResult : null; + return NormalizedQuantity.Of(meter, tank: null, declared); } } diff --git a/src/Infrastructure/Normalization/NormalizationUpgrade.cs b/src/Infrastructure/Normalization/NormalizationUpgrade.cs index 24fab14..402b031 100644 --- a/src/Infrastructure/Normalization/NormalizationUpgrade.cs +++ b/src/Infrastructure/Normalization/NormalizationUpgrade.cs @@ -8,8 +8,9 @@ using Microsoft.Extensions.Logging; namespace MeterVault.Infrastructure.Normalization; /// -/// Rebuilds every meter's stored consumption once after the normalization rules — or the timezone they -/// divide months in — change. +/// Rebuilds every meter's stored consumption — and the analysis data derived with it: rollups, coverage and +/// rollup state (D-12, D-16) — once after the normalization rules, or the timezone they divide months in, +/// change. /// /// /// @@ -23,6 +24,18 @@ namespace MeterVault.Infrastructure.Normalization; /// A meter that fails to rebuild is logged and remembered, never fatal: startup continues, and the next /// start retries just those meters. One bad series must not keep the whole application down. /// +/// +/// Each meter also records what its analysis data was built with (meter_rollup_state). A meter +/// without a state row, or with one from another revision or zone, is rebuilt at the next start even when +/// the stored revision is current — a meter restored from an export, or one skipped by the history guard +/// below — so no meter reads as "analysis being prepared" for longer than one restart. +/// +/// +/// A rebuild derives a meter from the readings and events that exist now. When stored consumption reaches +/// further back than any of them, older inputs are gone (removed by hand, or by a raw retention that this +/// build does not run, D-57) and the rebuild would silently cut that history off. Such a meter is skipped +/// and logged instead, keeps its stored rows, and is looked at again at every start (D-16). +/// /// public sealed class NormalizationUpgrade( MeterVaultDbContext db, NormalizationService normalization, ILogger logger) @@ -40,8 +53,18 @@ public sealed class NormalizationUpgrade( /// Bump whenever the engine books existing readings differently. /// 2: consumption between two readings is divided across the local months it spans; imported month /// rows are flagged as such and read as the end of their month. + /// 3: every row knows the interval it accrued over (D-10); a reading at exactly a local midnight is + /// booked in the day it closes (D-11); day and month rollups, coverage runs and rollup state are built + /// with consumption, and virtual meters store none of it (D-12, D-16). /// - public const int CurrentRevision = 2; + public const int CurrentRevision = 3; + + /// + /// How far a rebuilt row may legitimately lie before the meter's first reading or event: a row closing at + /// exactly a local midnight is stamped one second before it, inside the day it closes (D-11). Stored + /// consumption older than that is history the current inputs no longer explain. + /// + private static readonly TimeSpan StampTolerance = TimeSpan.FromSeconds(1); private const int ProgressEvery = 100; @@ -58,7 +81,8 @@ public sealed class NormalizationUpgrade( /// /// Rebuilds all meters if stored consumption predates or another - /// timezone, otherwise only meters left over from a failed rebuild. Returns how many were rebuilt. + /// timezone, otherwise only meters left over from a failed rebuild and meters whose rollup state is + /// missing or outdated. Returns how many were rebuilt (skipped and failed meters are not counted). /// public async Task RunAsync(CancellationToken cancellationToken = default) { @@ -82,7 +106,8 @@ public sealed class NormalizationUpgrade( var zone = _normalization.TimeZone.Id; var outdated = storedRevision is not { } revision || revision < CurrentRevision || storedZone != zone; - if (!outdated && pending.Length == 0) + List stale = outdated ? [] : await StaleStateAsync(zone, cancellationToken).ConfigureAwait(false); + if (!outdated && pending.Length == 0 && stale.Count == 0) { return 0; } @@ -95,24 +120,32 @@ public sealed class NormalizationUpgrade( return 0; } + // Virtual meters too: they store nothing, so the rebuild purges what they may hold and records their + // state. List meterIds = outdated ? await _db.Meters.AsNoTracking() - .Where(m => m.Mode != MeterMode.Virtual) .OrderBy(m => m.Id) .Select(m => m.Id) .ToListAsync(cancellationToken).ConfigureAwait(false) - : [.. pending.Order()]; + : [.. pending.Union(stale).Order()]; _logger.LogInformation( "Rebuilding stored consumption for {Meters} meter(s) (normalization revision {Revision}, timezone {Zone})", meterIds.Count, CurrentRevision, zone); var failed = new List(); + var skipped = new List(); for (var i = 0; i < meterIds.Count; i++) { var meterId = meterIds[i]; try { + if (await WouldTruncateHistoryAsync(meterId, cancellationToken).ConfigureAwait(false)) + { + skipped.Add(meterId); + continue; + } + // One transaction per meter: the rebuild deletes then re-inserts, and a meter must never be // left without consumption — but one meter's failure should not undo all the others. await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); @@ -141,20 +174,80 @@ public sealed class NormalizationUpgrade( await WriteAsync(PendingSettingKey, failed.ToArray(), cancellationToken).ConfigureAwait(false); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + if (skipped.Count > 0) + { + _logger.LogWarning( + "Consumption for {Skipped} meter(s) was not rebuilt because it reaches further back than their readings and events, and a rebuild would cut that history off; their analysis stays unavailable and they are checked again at the next start: {MeterIds}", + skipped.Count, string.Join(", ", skipped)); + } + if (failed.Count > 0) { _logger.LogWarning( "Consumption for {Failed} meter(s) could not be rebuilt and keeps its previous attribution until the next start: {MeterIds}", failed.Count, string.Join(", ", failed)); } - else + else if (skipped.Count == 0) { _logger.LogInformation("Stored consumption rebuilt to normalization revision {Revision}", CurrentRevision); } - return meterIds.Count - failed.Count; + return meterIds.Count - failed.Count - skipped.Count; } + /// + /// The meters whose analysis data is missing or was built with another revision or zone (D-16), although + /// the instance as a whole is current: restored from an export, created without a recompute, or skipped + /// by the history guard at an earlier start. + /// + private async Task> StaleStateAsync(string zone, CancellationToken cancellationToken) => + await _db.Meters.AsNoTracking() + .Where(m => !_db.MeterRollupStates.Any(s => s.MeterId == m.Id && s.Revision >= CurrentRevision && s.Zone == zone)) + .OrderBy(m => m.Id) + .Select(m => m.Id) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + /// + /// True when rebuilding a physical meter would drop stored history (D-16): its oldest consumption row is + /// older than its oldest reading and its oldest event, so the inputs that row came from are gone. Logged + /// with the dates. A virtual meter's stored rows are always purged; that is the point of rebuilding it. + /// + private async Task WouldTruncateHistoryAsync(int meterId, CancellationToken cancellationToken) + { + var isVirtual = await _db.Meters.AsNoTracking() + .AnyAsync(m => m.Id == meterId && m.Mode == MeterMode.Virtual, cancellationToken).ConfigureAwait(false); + if (isVirtual) + { + return false; + } + + var oldestConsumption = await _db.Consumption.AsNoTracking().Where(c => c.MeterId == meterId) + .MinAsync(c => (DateTimeOffset?)c.Time, cancellationToken).ConfigureAwait(false); + if (oldestConsumption is not { } stored) + { + return false; + } + + var oldestReading = await _db.Readings.AsNoTracking().Where(r => r.MeterId == meterId) + .MinAsync(r => (DateTimeOffset?)r.Time, cancellationToken).ConfigureAwait(false); + var oldestEvent = await _db.MeterEvents.AsNoTracking().Where(e => e.MeterId == meterId) + .MinAsync(e => (DateTimeOffset?)e.Time, cancellationToken).ConfigureAwait(false); + var oldestInput = Earliest(oldestReading, oldestEvent); + + if (oldestInput is { } input && stored >= input - StampTolerance) + { + return false; + } + + _logger.LogWarning( + "Meter {MeterId} is not rebuilt: its stored consumption starts at {Consumption}, but its oldest reading or event is at {Input}; rebuilding would cut off the history before that", + meterId, stored, oldestInput); + return true; + } + + private static DateTimeOffset? Earliest(DateTimeOffset? a, DateTimeOffset? b) => + a is null ? b : b is null ? a : a < b ? a : b; + /// /// Flags the rows of earlier imports that came from monthly tables as , /// which the importer only records since revision 2. Returns false when that could not be done. diff --git a/src/Infrastructure/Persistence/Analysis/AnalysisEntities.cs b/src/Infrastructure/Persistence/Analysis/AnalysisEntities.cs new file mode 100644 index 0000000..44ad6be --- /dev/null +++ b/src/Infrastructure/Persistence/Analysis/AnalysisEntities.cs @@ -0,0 +1,187 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Rollups; +using MeterVault.Core.Domain; + +namespace MeterVault.Infrastructure.Persistence.Analysis; + +// The analysis tables (D-12). All four are derived: RecomputeMeterAsync writes them from the same engine +// output it stores as consumption, in the caller's transaction, by diff. They are plain tables — not +// hypertables — each keyed by meter first, with an FK to meter that cascades, so deleting a meter (or +// wiping all of them) never trips over them. + +/// The columns a day and a month rollup share ( without its start). +public abstract class ConsumptionRollupBase +{ + public int MeterId { get; set; } + + /// Consumption or generation, as the consumption rows are stored. + public ConsumptionKind Kind { get; set; } + + /// The sum of the bucket's rows; signed. + public double Amount { get; set; } + + public double Measured { get; set; } + + public double Manual { get; set; } + + public double Imported { get; set; } + + /// Divided, coalesced, interpolated or otherwise inferred amounts. + public double Estimated { get; set; } + + /// How many consumption rows the bucket sums. + public int Rows { get; set; } + + /// Opening balance (1) and divided (2) markers. + public RollupFlags Flags { get; set; } + + /// The latest source-interval end among the bucket's rows (A-05), UTC. + public DateTimeOffset MaxIntervalEnd { get; set; } + + /// The bucket this row stores, with as its day or month. + protected RollupBucket ToBucket(DateOnly start) => + new(start, Kind, Amount, Measured, Manual, Imported, Estimated, Rows, Flags, MaxIntervalEnd); + + /// Copies every column but the key from ; true when anything changed. + internal bool Assign(RollupBucket bucket) + { + var end = bucket.MaxIntervalEnd.ToUniversalTime(); + if (Amount.Equals(bucket.Amount) && Measured.Equals(bucket.Measured) && Manual.Equals(bucket.Manual) + && Imported.Equals(bucket.Imported) && Estimated.Equals(bucket.Estimated) && Rows == bucket.Rows + && Flags == bucket.Flags && MaxIntervalEnd == end && MaxIntervalEnd.Offset == TimeSpan.Zero) + { + return false; + } + + Amount = bucket.Amount; + Measured = bucket.Measured; + Manual = bucket.Manual; + Imported = bucket.Imported; + Estimated = bucket.Estimated; + Rows = bucket.Rows; + Flags = bucket.Flags; + MaxIntervalEnd = end; + return true; + } +} + +/// One local day of one kind for one meter: table consumption_rollup, key (meter_id, day, kind). +public sealed class ConsumptionRollup : ConsumptionRollupBase +{ + /// The local calendar day in the instance zone. + public DateOnly Day { get; set; } + + public RollupBucket ToBucket() => ToBucket(Day); + + public static ConsumptionRollup From(int meterId, RollupBucket bucket) + { + ArgumentNullException.ThrowIfNull(bucket); + + var row = new ConsumptionRollup { MeterId = meterId, Day = bucket.Start, Kind = bucket.Kind }; + row.Assign(bucket); + return row; + } +} + +/// +/// One local month of one kind for one meter: table consumption_rollup_month, key (meter_id, month, kind). +/// Month and year reads use it. +/// +public sealed class ConsumptionRollupMonth : ConsumptionRollupBase +{ + /// The first day of the local month. + public DateOnly Month { get; set; } + + public RollupBucket ToBucket() => ToBucket(Month); + + public static ConsumptionRollupMonth From(int meterId, RollupBucket bucket) + { + ArgumentNullException.ThrowIfNull(bucket); + + var row = new ConsumptionRollupMonth { MeterId = meterId, Month = bucket.Start, Kind = bucket.Kind }; + row.Assign(bucket); + return row; + } +} + +/// +/// One stored coverage run of a meter (D-13): table meter_coverage, key (meter_id, span_from). Stored +/// uncapped (A-04); a reader caps it at its own now with . +/// +public sealed class MeterCoverageRun +{ + public int MeterId { get; set; } + + public DateTimeOffset SpanFrom { get; set; } + + public DateTimeOffset SpanTo { get; set; } + + public ResolutionClass ResolutionClass { get; set; } + + public bool DividedAtMonths { get; set; } + + /// Not for a known hole rather than covered time. + public CoverageGapReason GapReason { get; set; } + + /// Where the run's final interval starts (A-04). + public DateTimeOffset? LastIntervalStart { get; set; } + + public CoverageRun ToRun() => new(SpanFrom, SpanTo, ResolutionClass, DividedAtMonths, GapReason, LastIntervalStart); + + public static MeterCoverageRun From(int meterId, CoverageRun run) + { + ArgumentNullException.ThrowIfNull(run); + + var row = new MeterCoverageRun { MeterId = meterId, SpanFrom = run.From.ToUniversalTime() }; + row.Assign(run); + return row; + } + + /// Copies every column but the key from ; true when anything changed. + internal bool Assign(CoverageRun run) + { + var to = run.To.ToUniversalTime(); + var last = run.LastIntervalStart?.ToUniversalTime(); + if (SpanTo == to && SpanTo.Offset == TimeSpan.Zero && ResolutionClass == run.Resolution + && DividedAtMonths == run.DividedAtMonths && GapReason == run.Gap && Nullable.Equals(LastIntervalStart, last)) + { + return false; + } + + SpanTo = to; + ResolutionClass = run.Resolution; + DividedAtMonths = run.DividedAtMonths; + GapReason = run.Gap; + LastIntervalStart = last; + return true; + } +} + +/// +/// What a meter's stored analysis data was built with (D-16): table meter_rollup_state, one row per meter. +/// A reader whose revision or zone differs — or that finds no row — reports the meter's analysis as being +/// prepared, never as "no data". +/// +public sealed class MeterRollupState +{ + public int MeterId { get; set; } + + /// The normalization revision the rows were built with (NormalizationUpgrade.CurrentRevision). + public int Revision { get; set; } + + /// The timezone id local days and months were cut in. + public string Zone { get; set; } = string.Empty; + + /// The unit every amount of the meter is in (D-20); canonical spelling. + public string NormalizedUnit { get; set; } = string.Empty; + + /// What the meter's amounts measure (D-20). + public QuantityKind Kind { get; set; } + + /// + /// When the meter's analysis data last changed: a rollup or coverage row was added, changed or removed, or + /// one of the columns above changed. A recompute that reproduces the stored data leaves it alone, so it + /// doubles as a change marker for anything cached from these tables. + /// + public DateTimeOffset BuiltAt { get; set; } +} diff --git a/src/Infrastructure/Persistence/EntityDeletion.cs b/src/Infrastructure/Persistence/EntityDeletion.cs new file mode 100644 index 0000000..cf41664 --- /dev/null +++ b/src/Infrastructure/Persistence/EntityDeletion.cs @@ -0,0 +1,77 @@ +using MeterVault.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Persistence; + +/// +/// Deleting a meter or an energy type together with everything that names it by id. Most of that follows by foreign +/// key (events, sources, tank, category members, analysis rows); a tariff does not: tariff.scope_id names a +/// meter or an energy type depending on its scope and has no foreign key. A scoped price left behind would outlive its +/// meter, be exported, and on a restore land on whichever meter or type is given the dead id. +/// +public static class EntityDeletion +{ + /// + /// Deletes a meter, its raw readings and consumption (restricted foreign keys) and its meter-scoped tariffs, in one + /// transaction (the caller's, when one is open). + /// + public static async Task DeleteMeterAsync(MeterVaultDbContext db, int meterId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(db); + + var tx = db.Database.CurrentTransaction is null + ? await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false) + : null; + try + { + await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await db.Tariffs.Where(t => t.ScopeType == TariffScope.Meter && t.ScopeId == meterId) + .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + if (tx is not null) + { + await tx.CommitAsync(cancellationToken).ConfigureAwait(false); + } + } + finally + { + if (tx is not null) + { + await tx.DisposeAsync().ConfigureAwait(false); + } + } + } + + /// + /// Deletes an energy type and its type-scoped tariffs, in one transaction (the caller's, when one is open). The + /// caller checks first that no meter is of the type. True when the type existed. + /// + public static async Task DeleteEnergyTypeAsync(MeterVaultDbContext db, short energyTypeId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(db); + + var tx = db.Database.CurrentTransaction is null + ? await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false) + : null; + try + { + await db.Tariffs.Where(t => t.ScopeType == TariffScope.EnergyType && t.ScopeId == energyTypeId) + .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + var deleted = await db.EnergyTypes.Where(t => t.Id == energyTypeId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + if (tx is not null) + { + await tx.CommitAsync(cancellationToken).ConfigureAwait(false); + } + + return deleted > 0; + } + finally + { + if (tx is not null) + { + await tx.DisposeAsync().ConfigureAwait(false); + } + } + } +} diff --git a/src/Infrastructure/Persistence/MeterVaultDbContext.cs b/src/Infrastructure/Persistence/MeterVaultDbContext.cs index abf62af..671661e 100644 --- a/src/Infrastructure/Persistence/MeterVaultDbContext.cs +++ b/src/Infrastructure/Persistence/MeterVaultDbContext.cs @@ -1,4 +1,5 @@ using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Persistence.Analysis; using Microsoft.EntityFrameworkCore; namespace MeterVault.Infrastructure.Persistence; @@ -28,6 +29,10 @@ public sealed class MeterVaultDbContext(DbContextOptions op public DbSet IngestionEndpoints => Set(); public DbSet ImportBatches => Set(); public DbSet AppSettings => Set(); + public DbSet ConsumptionRollups => Set(); + public DbSet ConsumptionRollupMonths => Set(); + public DbSet MeterCoverage => Set(); + public DbSet MeterRollupStates => Set(); protected override void OnModelCreating(ModelBuilder b) { @@ -106,6 +111,16 @@ public sealed class MeterVaultDbContext(DbContextOptions op e.Property(x => x.Quality).HasConversion(); e.HasOne().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => x.ImportBatchId); + + // The source interval and its markers (D-10) are handed from the engine to the rollup and + // coverage writers within one recompute; the hypertable keeps only the stamp. + e.Ignore(x => x.IntervalStart); + e.Ignore(x => x.IntervalEnd); + e.Ignore(x => x.SourceStart); + e.Ignore(x => x.SourceEnd); + e.Ignore(x => x.Divided); + e.Ignore(x => x.OpeningBalance); + e.Ignore(x => x.Gap); }); b.Entity(e => @@ -195,5 +210,54 @@ public sealed class MeterVaultDbContext(DbContextOptions op e.Property(x => x.Key).HasMaxLength(128); e.Property(x => x.Value).HasColumnType("jsonb"); }); + + ConfigureAnalysis(b); + } + + /// + /// The analysis tables (D-12): plain tables derived from consumption within the same recompute, keyed by + /// meter first, cascading with their meter. + /// + private static void ConfigureAnalysis(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("consumption_rollup"); + e.HasKey(x => new { x.MeterId, x.Day, x.Kind }); + ConfigureRollupColumns(e); + }); + + b.Entity(e => + { + e.ToTable("consumption_rollup_month"); + e.HasKey(x => new { x.MeterId, x.Month, x.Kind }); + ConfigureRollupColumns(e); + }); + + b.Entity(e => + { + e.ToTable("meter_coverage"); + e.HasKey(x => new { x.MeterId, x.SpanFrom }); + e.Property(x => x.ResolutionClass).HasConversion(); + e.Property(x => x.GapReason).HasConversion(); + e.HasOne().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade); + }); + + b.Entity(e => + { + e.ToTable("meter_rollup_state"); + e.HasKey(x => x.MeterId); + e.Property(x => x.MeterId).ValueGeneratedNever(); + e.Property(x => x.Kind).HasConversion(); + e.HasOne().WithOne().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureRollupColumns(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder e) + where T : ConsumptionRollupBase + { + e.Property(x => x.Kind).HasConversion(); + e.Property(x => x.Flags).HasConversion(); + e.HasOne().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/Infrastructure/Persistence/Migrations/20260919090259_AnalysisRollups.Designer.cs b/src/Infrastructure/Persistence/Migrations/20260919090259_AnalysisRollups.Designer.cs new file mode 100644 index 0000000..502bc63 --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/20260919090259_AnalysisRollups.Designer.cs @@ -0,0 +1,1145 @@ +// +using System; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeterVault.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(MeterVaultDbContext))] + [Migration("20260919090259_AnalysisRollups")] + partial class AnalysisRollups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "timescaledb"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeterVault.Core.Domain.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_app_setting"); + + b.ToTable("app_setting", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b => + { + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("Time") + .HasColumnType("timestamp with time zone") + .HasColumnName("time"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("Amount") + .HasColumnType("double precision") + .HasColumnName("amount"); + + b.Property("ImportBatchId") + .HasColumnType("integer") + .HasColumnName("import_batch_id"); + + b.Property("Quality") + .HasColumnType("smallint") + .HasColumnName("quality"); + + b.HasKey("MeterId", "Time", "Kind") + .HasName("pk_consumption"); + + b.HasIndex("ImportBatchId") + .HasDatabaseName("ix_consumption_import_batch_id"); + + b.ToTable("consumption", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ColorHex") + .HasColumnType("text") + .HasColumnName("color_hex"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Sort") + .HasColumnType("integer") + .HasColumnName("sort"); + + b.HasKey("Id") + .HasName("pk_cost_category"); + + b.ToTable("cost_category", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("EnergyTypeId") + .HasColumnType("smallint") + .HasColumnName("energy_type_id"); + + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.HasKey("Id") + .HasName("pk_cost_category_member"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_cost_category_member_category_id"); + + b.HasIndex("EnergyTypeId") + .HasDatabaseName("ix_cost_category_member_energy_type_id"); + + b.HasIndex("MeterId") + .HasDatabaseName("ix_cost_category_member_meter_id"); + + b.ToTable("cost_category_member", null, t => + { + t.HasCheckConstraint("ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL"); + }); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BaseUnit") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("base_unit"); + + b.Property("ColorHex") + .HasColumnType("text") + .HasColumnName("color_hex"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DefaultMode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("default_mode"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("display_name"); + + b.Property("Icon") + .HasColumnType("text") + .HasColumnName("icon"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("key"); + + b.HasKey("Id") + .HasName("pk_energy_type"); + + b.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_energy_type_key"); + + b.ToTable("energy_type", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.ImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Mapping") + .HasColumnType("jsonb") + .HasColumnName("mapping"); + + b.Property("RevertedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reverted_at"); + + b.Property("RowCount") + .HasColumnType("integer") + .HasColumnName("row_count"); + + b.Property("SourceName") + .HasColumnType("text") + .HasColumnName("source_name"); + + b.HasKey("Id") + .HasName("pk_import_batch"); + + b.ToTable("import_batch", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.IngestionEndpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Config") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("LastStatus") + .HasColumnType("text") + .HasColumnName("last_status"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_ingestion_endpoint"); + + b.ToTable("ingestion_endpoint", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("double precision") + .HasColumnName("amount"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasColumnName("category_id"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("currency"); + + b.Property("ImportBatchId") + .HasColumnType("integer") + .HasColumnName("import_batch_id"); + + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("PeriodEnd") + .HasColumnType("date") + .HasColumnName("period_end"); + + b.Property("PeriodStart") + .HasColumnType("date") + .HasColumnName("period_start"); + + b.HasKey("Id") + .HasName("pk_manual_cost"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_manual_cost_category_id"); + + b.HasIndex("ImportBatchId") + .HasDatabaseName("ix_manual_cost_import_batch_id"); + + b.HasIndex("MeterId") + .HasDatabaseName("ix_manual_cost_meter_id"); + + b.ToTable("manual_cost", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.Meter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EnergyTypeId") + .HasColumnType("smallint") + .HasColumnName("energy_type_id"); + + b.Property("InitialBaseline") + .HasColumnType("double precision") + .HasColumnName("initial_baseline"); + + b.Property("InstalledAt") + .HasColumnType("date") + .HasColumnName("installed_at"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("Location") + .HasColumnType("text") + .HasColumnName("location"); + + b.Property("Manufacturer") + .HasColumnType("text") + .HasColumnName("manufacturer"); + + b.Property("Meta") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("meta") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("mode"); + + b.Property("Model") + .HasColumnType("text") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("date") + .HasColumnName("retired_at"); + + b.Property("SerialNumber") + .HasColumnType("text") + .HasColumnName("serial_number"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text") + .HasColumnName("unit"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_meter"); + + b.HasIndex("EnergyTypeId", "IsActive") + .HasDatabaseName("ix_meter_energy_type_id_is_active"); + + b.ToTable("meter", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("double precision") + .HasColumnName("amount"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("event_type"); + + b.Property("ImportBatchId") + .HasColumnType("integer") + .HasColumnName("import_batch_id"); + + b.Property("Meta") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("meta") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("NewValue") + .HasColumnType("double precision") + .HasColumnName("new_value"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("PrevValue") + .HasColumnType("double precision") + .HasColumnName("prev_value"); + + b.Property("Time") + .HasColumnType("timestamp with time zone") + .HasColumnName("time"); + + b.Property("Unit") + .HasColumnType("text") + .HasColumnName("unit"); + + b.HasKey("Id") + .HasName("pk_meter_event"); + + b.HasIndex("ImportBatchId") + .HasDatabaseName("ix_meter_event_import_batch_id"); + + b.HasIndex("MeterId", "Time") + .HasDatabaseName("ix_meter_event_meter_id_time"); + + b.ToTable("meter_event", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FromMeterId") + .HasColumnType("integer") + .HasColumnName("from_meter_id"); + + b.Property("ToMeterId") + .HasColumnType("integer") + .HasColumnName("to_meter_id"); + + b.HasKey("Id") + .HasName("pk_meter_link"); + + b.HasIndex("ToMeterId") + .HasDatabaseName("ix_meter_link_to_meter_id"); + + b.HasIndex("FromMeterId", "ToMeterId") + .IsUnique() + .HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id"); + + b.ToTable("meter_link", null, t => + { + t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id"); + }); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Config") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("EndpointId") + .HasColumnType("integer") + .HasColumnName("endpoint_id"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("LastStatus") + .HasColumnType("text") + .HasColumnName("last_status"); + + b.Property("LastValue") + .HasColumnType("double precision") + .HasColumnName("last_value"); + + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("Offset") + .HasColumnType("double precision") + .HasColumnName("offset"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("Scale") + .ValueGeneratedOnAdd() + .HasColumnType("double precision") + .HasDefaultValue(1.0) + .HasColumnName("scale"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source_type"); + + b.Property("ValueKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("value_kind"); + + b.HasKey("Id") + .HasName("pk_meter_source"); + + b.HasIndex("EndpointId") + .HasDatabaseName("ix_meter_source_endpoint_id"); + + b.HasIndex("MeterId") + .HasDatabaseName("ix_meter_source_meter_id"); + + b.ToTable("meter_source", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.Reading", b => + { + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("Time") + .HasColumnType("timestamp with time zone") + .HasColumnName("time"); + + b.Property("Flags") + .HasColumnType("integer") + .HasColumnName("flags"); + + b.Property("ImportBatchId") + .HasColumnType("integer") + .HasColumnName("import_batch_id"); + + b.Property("Quality") + .HasColumnType("smallint") + .HasColumnName("quality"); + + b.Property("SourceId") + .HasColumnType("integer") + .HasColumnName("source_id"); + + b.Property("Value") + .HasColumnType("double precision") + .HasColumnName("value"); + + b.HasKey("MeterId", "Time") + .HasName("pk_reading"); + + b.HasIndex("ImportBatchId") + .HasDatabaseName("ix_reading_import_batch_id"); + + b.ToTable("reading", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.Tank", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CachedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("cached_at"); + + b.Property("CachedBalance") + .HasColumnType("double precision") + .HasColumnName("cached_balance"); + + b.Property("Calibration") + .HasColumnType("jsonb") + .HasColumnName("calibration"); + + b.Property("Capacity") + .HasColumnType("double precision") + .HasColumnName("capacity"); + + b.Property("FixedRate") + .HasColumnType("double precision") + .HasColumnName("fixed_rate"); + + b.Property("LowThreshold") + .HasColumnType("double precision") + .HasColumnName("low_threshold"); + + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("RateMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("rate_mode"); + + b.Property("ReorderThreshold") + .HasColumnType("double precision") + .HasColumnName("reorder_threshold"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("unit"); + + b.HasKey("Id") + .HasName("pk_tank"); + + b.HasIndex("MeterId") + .IsUnique() + .HasDatabaseName("ix_tank_meter_id"); + + b.ToTable("tank", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.Tariff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Component") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("component"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("currency"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("ScopeId") + .HasColumnType("integer") + .HasColumnName("scope_id"); + + b.Property("ScopeType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("scope_type"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("unit"); + + b.Property("ValidFrom") + .HasColumnType("date") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("date") + .HasColumnName("valid_to"); + + b.Property("Value") + .HasColumnType("double precision") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_tariff"); + + b.HasIndex("ScopeType", "ScopeId", "Component", "ValidFrom") + .HasDatabaseName("ix_tariff_scope_type_scope_id_component_valid_from"); + + b.ToTable("tariff", (string)null); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.ConsumptionRollup", b => + { + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("Day") + .HasColumnType("date") + .HasColumnName("day"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("Amount") + .HasColumnType("double precision") + .HasColumnName("amount"); + + b.Property("Estimated") + .HasColumnType("double precision") + .HasColumnName("estimated"); + + b.Property("Flags") + .HasColumnType("integer") + .HasColumnName("flags"); + + b.Property("Imported") + .HasColumnType("double precision") + .HasColumnName("imported"); + + b.Property("Manual") + .HasColumnType("double precision") + .HasColumnName("manual"); + + b.Property("MaxIntervalEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("max_interval_end"); + + b.Property("Measured") + .HasColumnType("double precision") + .HasColumnName("measured"); + + b.Property("Rows") + .HasColumnType("integer") + .HasColumnName("rows"); + + b.HasKey("MeterId", "Day", "Kind") + .HasName("pk_consumption_rollup"); + + b.ToTable("consumption_rollup", (string)null); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.ConsumptionRollupMonth", b => + { + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("Month") + .HasColumnType("date") + .HasColumnName("month"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("Amount") + .HasColumnType("double precision") + .HasColumnName("amount"); + + b.Property("Estimated") + .HasColumnType("double precision") + .HasColumnName("estimated"); + + b.Property("Flags") + .HasColumnType("integer") + .HasColumnName("flags"); + + b.Property("Imported") + .HasColumnType("double precision") + .HasColumnName("imported"); + + b.Property("Manual") + .HasColumnType("double precision") + .HasColumnName("manual"); + + b.Property("MaxIntervalEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("max_interval_end"); + + b.Property("Measured") + .HasColumnType("double precision") + .HasColumnName("measured"); + + b.Property("Rows") + .HasColumnType("integer") + .HasColumnName("rows"); + + b.HasKey("MeterId", "Month", "Kind") + .HasName("pk_consumption_rollup_month"); + + b.ToTable("consumption_rollup_month", (string)null); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.MeterCoverageRun", b => + { + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("SpanFrom") + .HasColumnType("timestamp with time zone") + .HasColumnName("span_from"); + + b.Property("DividedAtMonths") + .HasColumnType("boolean") + .HasColumnName("divided_at_months"); + + b.Property("GapReason") + .HasColumnType("smallint") + .HasColumnName("gap_reason"); + + b.Property("LastIntervalStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_interval_start"); + + b.Property("ResolutionClass") + .HasColumnType("smallint") + .HasColumnName("resolution_class"); + + b.Property("SpanTo") + .HasColumnType("timestamp with time zone") + .HasColumnName("span_to"); + + b.HasKey("MeterId", "SpanFrom") + .HasName("pk_meter_coverage"); + + b.ToTable("meter_coverage", (string)null); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.MeterRollupState", b => + { + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("BuiltAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("built_at"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("NormalizedUnit") + .IsRequired() + .HasColumnType("text") + .HasColumnName("normalized_unit"); + + b.Property("Revision") + .HasColumnType("integer") + .HasColumnName("revision"); + + b.Property("Zone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("zone"); + + b.HasKey("MeterId") + .HasName("pk_meter_rollup_state"); + + b.ToTable("meter_rollup_state", (string)null); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_consumption_meter_meter_id"); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b => + { + b.HasOne("MeterVault.Core.Domain.CostCategory", "Category") + .WithMany("Members") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_cost_category_member_cost_category_category_id"); + + b.HasOne("MeterVault.Core.Domain.EnergyType", null) + .WithMany() + .HasForeignKey("EnergyTypeId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_cost_category_member_energy_type_energy_type_id"); + + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_cost_category_member_meter_meter_id"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b => + { + b.HasOne("MeterVault.Core.Domain.CostCategory", null) + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_manual_cost_cost_category_category_id"); + + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_manual_cost_meter_meter_id"); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.Meter", b => + { + b.HasOne("MeterVault.Core.Domain.EnergyType", "EnergyType") + .WithMany("Meters") + .HasForeignKey("EnergyTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_meter_energy_type_energy_type_id"); + + b.Navigation("EnergyType"); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_meter_event_meter_meter_id"); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter") + .WithMany() + .HasForeignKey("FromMeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_meter_link_meter_from_meter_id"); + + b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter") + .WithMany() + .HasForeignKey("ToMeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_meter_link_meter_to_meter_id"); + + b.Navigation("FromMeter"); + + b.Navigation("ToMeter"); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b => + { + b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint") + .WithMany() + .HasForeignKey("EndpointId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_meter_source_ingestion_endpoints_endpoint_id"); + + b.HasOne("MeterVault.Core.Domain.Meter", "Meter") + .WithMany("Sources") + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_meter_source_meter_meter_id"); + + b.Navigation("Endpoint"); + + b.Navigation("Meter"); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.Reading", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_reading_meter_meter_id"); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.Tank", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", "Meter") + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tank_meter_meter_id"); + + b.Navigation("Meter"); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.ConsumptionRollup", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_consumption_rollup_meter_meter_id"); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.ConsumptionRollupMonth", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_consumption_rollup_month_meter_meter_id"); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.MeterCoverageRun", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_meter_coverage_meter_meter_id"); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.MeterRollupState", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithOne() + .HasForeignKey("MeterVault.Infrastructure.Persistence.Analysis.MeterRollupState", "MeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_meter_rollup_state_meter_meter_id"); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b => + { + b.Navigation("Meters"); + }); + + modelBuilder.Entity("MeterVault.Core.Domain.Meter", b => + { + b.Navigation("Sources"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Persistence/Migrations/20260919090259_AnalysisRollups.cs b/src/Infrastructure/Persistence/Migrations/20260919090259_AnalysisRollups.cs new file mode 100644 index 0000000..33fefb2 --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/20260919090259_AnalysisRollups.cs @@ -0,0 +1,201 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeterVault.Infrastructure.Persistence.Migrations +{ + /// + /// The analysis tables (D-12) replace the continuous aggregates (D-17). Rollups, coverage runs and rollup + /// state are plain tables written by the recompute in the caller's transaction, in the configured zone, with + /// provenance and coverage the Berlin-only, amount-only aggregates never had — and which no reader used. + /// + /// + /// + /// Removing a continuous-aggregate policy and dropping the view cannot share a transaction with other DDL + /// reliably, so each is its own statement with suppressTransaction: true (as in ContinuousAggregates), + /// and they run first: every one is idempotent (if_exists / IF EXISTS), so a crash after them leaves a state + /// the next migrate simply repeats. The tables and the purge then run in one transaction together with the + /// migration history row. + /// + /// + /// The tables start empty. NormalizationUpgrade (revision 3) rebuilds every meter at the next start + /// and fills them. The purge drops consumption stored for virtual meters, which are evaluated on read from + /// their sources (D-16, D-27); Down cannot bring those rows back, and does not need to — nothing ever read + /// them. + /// + /// + public partial class AnalysisRollups : Migration + { + private static readonly string[] Aggregates = ["consumption_daily", "consumption_monthly", "consumption_yearly"]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + foreach (var name in Aggregates) + { + // The view name is only resolved while the view exists: a rerun after the drop below finds nothing + // to remove instead of failing on an unknown relation. + migrationBuilder.Sql( + $"DO $$ BEGIN IF to_regclass('{name}') IS NOT NULL THEN " + + $"PERFORM remove_continuous_aggregate_policy('{name}', if_exists => true); END IF; END $$;", + suppressTransaction: true); + } + + foreach (var name in Aggregates) + { + migrationBuilder.Sql($"DROP MATERIALIZED VIEW IF EXISTS {name};", suppressTransaction: true); + } + + migrationBuilder.CreateTable( + name: "consumption_rollup", + columns: table => new + { + meter_id = table.Column(type: "integer", nullable: false), + kind = table.Column(type: "smallint", nullable: false), + day = table.Column(type: "date", nullable: false), + amount = table.Column(type: "double precision", nullable: false), + measured = table.Column(type: "double precision", nullable: false), + manual = table.Column(type: "double precision", nullable: false), + imported = table.Column(type: "double precision", nullable: false), + estimated = table.Column(type: "double precision", nullable: false), + rows = table.Column(type: "integer", nullable: false), + flags = table.Column(type: "integer", nullable: false), + max_interval_end = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_consumption_rollup", x => new { x.meter_id, x.day, x.kind }); + table.ForeignKey( + name: "fk_consumption_rollup_meter_meter_id", + column: x => x.meter_id, + principalTable: "meter", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "consumption_rollup_month", + columns: table => new + { + meter_id = table.Column(type: "integer", nullable: false), + kind = table.Column(type: "smallint", nullable: false), + month = table.Column(type: "date", nullable: false), + amount = table.Column(type: "double precision", nullable: false), + measured = table.Column(type: "double precision", nullable: false), + manual = table.Column(type: "double precision", nullable: false), + imported = table.Column(type: "double precision", nullable: false), + estimated = table.Column(type: "double precision", nullable: false), + rows = table.Column(type: "integer", nullable: false), + flags = table.Column(type: "integer", nullable: false), + max_interval_end = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_consumption_rollup_month", x => new { x.meter_id, x.month, x.kind }); + table.ForeignKey( + name: "fk_consumption_rollup_month_meter_meter_id", + column: x => x.meter_id, + principalTable: "meter", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "meter_coverage", + columns: table => new + { + meter_id = table.Column(type: "integer", nullable: false), + span_from = table.Column(type: "timestamp with time zone", nullable: false), + span_to = table.Column(type: "timestamp with time zone", nullable: false), + resolution_class = table.Column(type: "smallint", nullable: false), + divided_at_months = table.Column(type: "boolean", nullable: false), + gap_reason = table.Column(type: "smallint", nullable: false), + last_interval_start = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_meter_coverage", x => new { x.meter_id, x.span_from }); + table.ForeignKey( + name: "fk_meter_coverage_meter_meter_id", + column: x => x.meter_id, + principalTable: "meter", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "meter_rollup_state", + columns: table => new + { + meter_id = table.Column(type: "integer", nullable: false), + revision = table.Column(type: "integer", nullable: false), + zone = table.Column(type: "text", nullable: false), + normalized_unit = table.Column(type: "text", nullable: false), + kind = table.Column(type: "smallint", nullable: false), + built_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_meter_rollup_state", x => x.meter_id); + table.ForeignKey( + name: "fk_meter_rollup_state_meter_meter_id", + column: x => x.meter_id, + principalTable: "meter", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + // Virtual meters store nothing (D-16): their values are evaluated on read from their sources. + migrationBuilder.Sql( + "DELETE FROM consumption WHERE meter_id IN (SELECT id FROM meter WHERE mode = 'Virtual');"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "consumption_rollup"); + + migrationBuilder.DropTable( + name: "consumption_rollup_month"); + + migrationBuilder.DropTable( + name: "meter_coverage"); + + migrationBuilder.DropTable( + name: "meter_rollup_state"); + + // The aggregates as ContinuousAggregates created them: Berlin buckets, created empty, refreshed by + // their policies. Re-run-safe for the same reason as there. + CreateAggregate(migrationBuilder, "consumption_daily", "1 day"); + CreateAggregate(migrationBuilder, "consumption_monthly", "1 month"); + CreateAggregate(migrationBuilder, "consumption_yearly", "1 year"); + + AddPolicy(migrationBuilder, "consumption_daily", startOffset: "30 days", endOffset: "1 day"); + AddPolicy(migrationBuilder, "consumption_monthly", startOffset: "1 year", endOffset: "1 month"); + AddPolicy(migrationBuilder, "consumption_yearly", startOffset: "10 years", endOffset: "1 year"); + } + + private static void CreateAggregate(MigrationBuilder builder, string name, string bucket) + { + builder.Sql( + $"CREATE MATERIALIZED VIEW IF NOT EXISTS {name} WITH (timescaledb.continuous) AS " + + $"SELECT time_bucket(INTERVAL '{bucket}', time, 'Europe/Berlin') AS bucket, " + + "meter_id, kind, sum(amount) AS amount " + + "FROM consumption GROUP BY bucket, meter_id, kind WITH NO DATA;", + suppressTransaction: true); + } + + private static void AddPolicy(MigrationBuilder builder, string name, string startOffset, string endOffset) + { + builder.Sql( + $"SELECT add_continuous_aggregate_policy('{name}', " + + $"start_offset => INTERVAL '{startOffset}', " + + $"end_offset => INTERVAL '{endOffset}', " + + "schedule_interval => INTERVAL '1 hour', " + + "if_not_exists => true);", + suppressTransaction: true); + } + } +} diff --git a/src/Infrastructure/Persistence/Migrations/MeterVaultDbContextModelSnapshot.cs b/src/Infrastructure/Persistence/Migrations/MeterVaultDbContextModelSnapshot.cs index 679098a..7fa97bc 100644 --- a/src/Infrastructure/Persistence/Migrations/MeterVaultDbContextModelSnapshot.cs +++ b/src/Infrastructure/Persistence/Migrations/MeterVaultDbContextModelSnapshot.cs @@ -774,6 +774,180 @@ namespace MeterVault.Infrastructure.Persistence.Migrations b.ToTable("tariff", (string)null); }); + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.ConsumptionRollup", b => + { + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("Day") + .HasColumnType("date") + .HasColumnName("day"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("Amount") + .HasColumnType("double precision") + .HasColumnName("amount"); + + b.Property("Estimated") + .HasColumnType("double precision") + .HasColumnName("estimated"); + + b.Property("Flags") + .HasColumnType("integer") + .HasColumnName("flags"); + + b.Property("Imported") + .HasColumnType("double precision") + .HasColumnName("imported"); + + b.Property("Manual") + .HasColumnType("double precision") + .HasColumnName("manual"); + + b.Property("MaxIntervalEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("max_interval_end"); + + b.Property("Measured") + .HasColumnType("double precision") + .HasColumnName("measured"); + + b.Property("Rows") + .HasColumnType("integer") + .HasColumnName("rows"); + + b.HasKey("MeterId", "Day", "Kind") + .HasName("pk_consumption_rollup"); + + b.ToTable("consumption_rollup", (string)null); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.ConsumptionRollupMonth", b => + { + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("Month") + .HasColumnType("date") + .HasColumnName("month"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("Amount") + .HasColumnType("double precision") + .HasColumnName("amount"); + + b.Property("Estimated") + .HasColumnType("double precision") + .HasColumnName("estimated"); + + b.Property("Flags") + .HasColumnType("integer") + .HasColumnName("flags"); + + b.Property("Imported") + .HasColumnType("double precision") + .HasColumnName("imported"); + + b.Property("Manual") + .HasColumnType("double precision") + .HasColumnName("manual"); + + b.Property("MaxIntervalEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("max_interval_end"); + + b.Property("Measured") + .HasColumnType("double precision") + .HasColumnName("measured"); + + b.Property("Rows") + .HasColumnType("integer") + .HasColumnName("rows"); + + b.HasKey("MeterId", "Month", "Kind") + .HasName("pk_consumption_rollup_month"); + + b.ToTable("consumption_rollup_month", (string)null); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.MeterCoverageRun", b => + { + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("SpanFrom") + .HasColumnType("timestamp with time zone") + .HasColumnName("span_from"); + + b.Property("DividedAtMonths") + .HasColumnType("boolean") + .HasColumnName("divided_at_months"); + + b.Property("GapReason") + .HasColumnType("smallint") + .HasColumnName("gap_reason"); + + b.Property("LastIntervalStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_interval_start"); + + b.Property("ResolutionClass") + .HasColumnType("smallint") + .HasColumnName("resolution_class"); + + b.Property("SpanTo") + .HasColumnType("timestamp with time zone") + .HasColumnName("span_to"); + + b.HasKey("MeterId", "SpanFrom") + .HasName("pk_meter_coverage"); + + b.ToTable("meter_coverage", (string)null); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.MeterRollupState", b => + { + b.Property("MeterId") + .HasColumnType("integer") + .HasColumnName("meter_id"); + + b.Property("BuiltAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("built_at"); + + b.Property("Kind") + .HasColumnType("smallint") + .HasColumnName("kind"); + + b.Property("NormalizedUnit") + .IsRequired() + .HasColumnType("text") + .HasColumnName("normalized_unit"); + + b.Property("Revision") + .HasColumnType("integer") + .HasColumnName("revision"); + + b.Property("Zone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("zone"); + + b.HasKey("MeterId") + .HasName("pk_meter_rollup_state"); + + b.ToTable("meter_rollup_state", (string)null); + }); + modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b => { b.HasOne("MeterVault.Core.Domain.Meter", null) @@ -908,6 +1082,46 @@ namespace MeterVault.Infrastructure.Persistence.Migrations b.Navigation("Meter"); }); + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.ConsumptionRollup", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_consumption_rollup_meter_meter_id"); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.ConsumptionRollupMonth", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_consumption_rollup_month_meter_meter_id"); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.MeterCoverageRun", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithMany() + .HasForeignKey("MeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_meter_coverage_meter_meter_id"); + }); + + modelBuilder.Entity("MeterVault.Infrastructure.Persistence.Analysis.MeterRollupState", b => + { + b.HasOne("MeterVault.Core.Domain.Meter", null) + .WithOne() + .HasForeignKey("MeterVault.Infrastructure.Persistence.Analysis.MeterRollupState", "MeterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_meter_rollup_state_meter_meter_id"); + }); + modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b => { b.Navigation("Members"); diff --git a/tests/Core.Tests/Analysis/AnalysisClock.cs b/tests/Core.Tests/Analysis/AnalysisClock.cs new file mode 100644 index 0000000..08ed09e --- /dev/null +++ b/tests/Core.Tests/Analysis/AnalysisClock.cs @@ -0,0 +1,38 @@ +using System.Globalization; + +namespace MeterVault.Core.Tests.Analysis; + +/// Frozen instants and zones for the period tests — nothing here reads the wall clock. +internal static class AnalysisClock +{ + public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); + + public static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + + /// An unambiguous local wall-clock time in , as an instant. + public static DateTimeOffset At(TimeZoneInfo zone, int year, int month, int day, int hour = 0, int minute = 0) + { + var wall = new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Unspecified); + Assert.False(zone.IsInvalidTime(wall), $"{wall:s} does not exist in {zone.Id}"); + Assert.False(zone.IsAmbiguousTime(wall), $"{wall:s} is ambiguous in {zone.Id}; give the offset"); + return new DateTimeOffset(wall, zone.GetUtcOffset(wall)); + } + + /// A local wall-clock time with an explicit offset — for the hour an autumn fold repeats. + public static DateTimeOffset At(int year, int month, int day, int hour, int minute, int offsetHours) => + new(year, month, day, hour, minute, 0, TimeSpan.FromHours(offsetHours)); + + public static DateTimeOffset Utc(int year, int month, int day, int hour = 0, int minute = 0) => + new(year, month, day, hour, minute, 0, TimeSpan.Zero); + + public static DateOnly Day(int year, int month, int day) => new(year, month, day); + + public static DateOnly Iso(string date) => DateOnly.ParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture); + + public static DateTimeOffset IsoInstant(string instant) => + DateTimeOffset.ParseExact(instant, "yyyy-MM-dd'T'HH:mm'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + + /// The wall clock an instant shows in , for readable assertions. + public static string Wall(DateTimeOffset instant, TimeZoneInfo zone) => + TimeZoneInfo.ConvertTime(instant, zone).ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture); +} diff --git a/tests/Core.Tests/Analysis/AnalysisTestTime.cs b/tests/Core.Tests/Analysis/AnalysisTestTime.cs new file mode 100644 index 0000000..d39f23c --- /dev/null +++ b/tests/Core.Tests/Analysis/AnalysisTestTime.cs @@ -0,0 +1,26 @@ +namespace MeterVault.Core.Tests.Analysis; + +/// Fixed instants in the zones the interval and coverage tests reason about. +internal static class AnalysisTestTime +{ + public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); + + public static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + + /// A wall-clock time in as an instant. + public static DateTimeOffset Local(TimeZoneInfo zone, int year, int month, int day, int hour = 0, int minute = 0, int second = 0) + { + var wall = new DateTime(year, month, day, hour, minute, second); + return new DateTimeOffset(wall, zone.GetUtcOffset(wall)); + } + + public static DateTimeOffset InBerlin(int year, int month, int day, int hour = 0, int minute = 0, int second = 0) => + Local(Berlin, year, month, day, hour, minute, second); + + public static DateTimeOffset Utc(int year, int month, int day, int hour = 0, int minute = 0) => + new(year, month, day, hour, minute, 0, TimeSpan.Zero); + + /// The local calendar date an instant falls on. + public static DateOnly LocalDate(DateTimeOffset instant, TimeZoneInfo zone) => + DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(instant, zone).DateTime); +} diff --git a/tests/Core.Tests/Analysis/AnalysisTokensTests.cs b/tests/Core.Tests/Analysis/AnalysisTokensTests.cs new file mode 100644 index 0000000..40244ad --- /dev/null +++ b/tests/Core.Tests/Analysis/AnalysisTokensTests.cs @@ -0,0 +1,226 @@ +using System.Globalization; +using MeterVault.Core.Analysis; +using static MeterVault.Core.Tests.Analysis.AnalysisClock; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// URL tokens are stable invariant identifiers (D-02, D-46): what a German browser writes, an English one +/// reads. Parsing never throws, so a hand-edited or stale link falls back to the page default instead of +/// breaking the page. +/// +public sealed class AnalysisTokensTests +{ + [Theory] + [InlineData(PeriodPreset.MonthToDate, "mtd")] + [InlineData(PeriodPreset.LastMonth, "last-month")] + [InlineData(PeriodPreset.YearToDate, "ytd")] + [InlineData(PeriodPreset.PreviousYear, "prev-year")] + [InlineData(PeriodPreset.Last12Months, "12m")] + [InlineData(PeriodPreset.Last24Months, "24m")] + [InlineData(PeriodPreset.AllHistory, "all")] + [InlineData(PeriodPreset.Custom, "custom")] + public void Every_period_preset_has_its_documented_token_and_parses_back(PeriodPreset preset, string token) + { + Assert.Equal(token, AnalysisTokens.Format(preset)); + Assert.True(AnalysisTokens.TryParsePeriod(token, out var parsed)); + Assert.Equal(preset, parsed); + } + + [Theory] + [InlineData(BucketSize.Auto, "auto")] + [InlineData(BucketSize.Day, "day")] + [InlineData(BucketSize.Week, "week")] + [InlineData(BucketSize.Month, "month")] + [InlineData(BucketSize.Year, "year")] + public void Every_bucket_size_has_its_documented_token_and_parses_back(BucketSize size, string token) + { + Assert.Equal(token, AnalysisTokens.Format(size)); + Assert.True(AnalysisTokens.TryParseBucket(token, out var parsed)); + Assert.Equal(size, parsed); + } + + [Fact] + public void Every_enum_value_has_a_token_so_no_state_is_unlinkable() + { + Assert.All(Enum.GetValues(), p => Assert.False(string.IsNullOrEmpty(AnalysisTokens.Format(p)))); + Assert.All(Enum.GetValues(), b => Assert.False(string.IsNullOrEmpty(AnalysisTokens.Format(b)))); + } + + [Theory] + [InlineData("none", ComparisonKind.None, null)] + [InlineData("prev-period", ComparisonKind.PreviousPeriod, null)] + [InlineData("prev-year", ComparisonKind.PreviousYear, null)] + [InlineData("year:2025", ComparisonKind.Year, 2025)] + [InlineData("year:1997", ComparisonKind.Year, 1997)] + public void Comparison_tokens_round_trip(string token, ComparisonKind kind, int? year) + { + Assert.True(AnalysisTokens.TryParseComparison(token, out var request)); + Assert.Equal(new ComparisonRequest(kind, year), request); + Assert.Equal(token, AnalysisTokens.Format(request)); + } + + [Fact] + public void Parsing_none_returns_the_shared_none_request() + { + Assert.True(AnalysisTokens.TryParseComparison("none", out var request)); + Assert.Same(ComparisonRequest.None, request); + } + + [Theory] + [InlineData("MTD", PeriodPreset.MonthToDate)] + [InlineData(" 12m ", PeriodPreset.Last12Months)] + [InlineData("Last-Month", PeriodPreset.LastMonth)] + public void Period_tokens_are_read_regardless_of_case_and_surrounding_blanks(string token, PeriodPreset expected) + { + Assert.True(AnalysisTokens.TryParsePeriod(token, out var parsed)); + Assert.Equal(expected, parsed); + } + + [Theory] + [InlineData("previous-year", ComparisonKind.PreviousYear)] + [InlineData("previous-period", ComparisonKind.PreviousPeriod)] + [InlineData("PREV-YEAR", ComparisonKind.PreviousYear)] + [InlineData(" Year:2024 ", ComparisonKind.Year)] + public void Spelled_out_comparison_aliases_are_accepted_but_never_written(string token, ComparisonKind expected) + { + Assert.True(AnalysisTokens.TryParseComparison(token, out var request)); + Assert.Equal(expected, request.Kind); + Assert.DoesNotContain("previous", AnalysisTokens.Format(request), StringComparison.Ordinal); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("month-to-date")] + [InlineData("13m")] + [InlineData("today")] + [InlineData("mtd;drop table")] + [InlineData("mtd2")] + public void Unknown_period_tokens_are_rejected_without_throwing(string? token) + { + Assert.False(AnalysisTokens.TryParsePeriod(token, out _)); + Assert.False(AnalysisTokens.TryParseBucket(token, out _)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("year")] + [InlineData("year:")] + [InlineData("year:25")] + [InlineData("year:20250")] + [InlineData("year:-202")] + [InlineData("year:+202")] + [InlineData("year: 2025")] + [InlineData("year:2025.0")] + [InlineData("year:2025")] // full-width digits + [InlineData("year:1899")] + [InlineData("year:2300")] + [InlineData("year:abcd")] + [InlineData("last-year")] + public void Malformed_or_out_of_range_comparison_tokens_are_rejected_without_throwing(string? token) + { + Assert.False(AnalysisTokens.TryParseComparison(token, out var request)); + Assert.Null(request); + } + + [Fact] + public void A_long_garbage_token_is_rejected_without_throwing() + { + var garbage = new string('x', 100_000); + + Assert.False(AnalysisTokens.TryParsePeriod(garbage, out _)); + Assert.False(AnalysisTokens.TryParseComparison("year:" + garbage, out _)); + Assert.False(AnalysisTokens.TryParseDate(garbage, out _)); + } + + [Theory] + [InlineData("2026-09-19", 2026, 9, 19)] + [InlineData("2028-02-29", 2028, 2, 29)] + [InlineData("1900-01-01", 1900, 1, 1)] + [InlineData("2299-12-31", 2299, 12, 31)] + public void Iso_dates_parse_exactly(string token, int year, int month, int day) + { + Assert.True(AnalysisTokens.TryParseDate(token, out var date)); + Assert.Equal(Day(year, month, day), date); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("2026-9-19")] + [InlineData("19.09.2026")] + [InlineData("09/19/2026")] + [InlineData("2026-02-29")] + [InlineData("2026-13-01")] + [InlineData("2026-09-19T00:00")] + [InlineData("1899-12-31")] + [InlineData("2300-01-01")] + [InlineData("9999-12-31")] + public void Dates_in_any_other_layout_or_outside_the_supported_range_are_rejected(string? token) + { + Assert.False(AnalysisTokens.TryParseDate(token, out var date)); + Assert.Equal(default(DateOnly), date); + } + + [Fact] + public void Dates_are_written_invariantly_whatever_the_reader_culture() + { + var saved = CultureInfo.CurrentCulture; + try + { + foreach (var culture in new[] { "de-DE", "ar-SA", "th-TH" }) + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + Assert.Equal("2026-09-19", AnalysisTokens.FormatDate(Day(2026, 9, 19))); + Assert.Equal("year:2025", AnalysisTokens.Format(new ComparisonRequest(ComparisonKind.Year, 2025))); + Assert.True(AnalysisTokens.TryParseDate("2026-09-19", out var parsed)); + Assert.Equal(Day(2026, 9, 19), parsed); + } + } + finally + { + CultureInfo.CurrentCulture = saved; + } + } + + [Fact] + public void A_custom_range_needs_both_dates_in_order() + { + Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-01", "2026-09-30", out var first, out var last)); + Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 30)), (first, last)); + + Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-19", "2026-09-19", out _, out _)); + Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-30", "2026-09-01", out _, out _)); + Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-01", null, out _, out _)); + Assert.False(AnalysisTokens.TryParseCustomRange(null, "2026-09-30", out _, out _)); + Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-01", "30.09.2026", out _, out _)); + } + + [Fact] + public void A_parsed_custom_range_resolves_without_error() + { + Assert.True(AnalysisTokens.TryParsePeriod("custom", out var preset)); + Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-01", "2026-12-31", out var first, out var last)); + + var period = PeriodResolver.Resolve(preset, first, last, At(Berlin, 2026, 9, 19, 14, 37), Berlin); + + Assert.True(period.ExtendsPastNow); + Assert.Equal("2026-12-31", AnalysisTokens.FormatDate(period.LastDay)); + } + + [Fact] + public void Formatting_a_year_comparison_without_a_year_is_a_programming_error() + { + Assert.Throws(() => AnalysisTokens.Format(new ComparisonRequest(ComparisonKind.Year))); + } + + [Fact] + public void Formatting_an_undefined_enum_value_is_a_programming_error() + { + Assert.Throws(() => AnalysisTokens.Format((PeriodPreset)99)); + Assert.Throws(() => AnalysisTokens.Format((BucketSize)99)); + } +} diff --git a/tests/Core.Tests/Analysis/BucketPlannerTests.cs b/tests/Core.Tests/Analysis/BucketPlannerTests.cs new file mode 100644 index 0000000..c9c3e51 --- /dev/null +++ b/tests/Core.Tests/Analysis/BucketPlannerTests.cs @@ -0,0 +1,502 @@ +using MeterVault.Core.Analysis; +using static MeterVault.Core.Tests.Analysis.AnalysisClock; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// Buckets are local calendar units clipped to the period (D-05). What these pin down: "last 12 months" is +/// exactly 12 buckets ending at now; weeks start on Monday; DST days are 23 or 25 hours; Auto picks one +/// sensible size that never undercuts the data's resolution; an explicit size over 400 points is refused +/// with a coarser suggestion rather than truncated. +/// +public sealed class BucketPlannerTests +{ + private static readonly DateTimeOffset September19 = At(Berlin, 2026, 9, 19, 14, 37); + + private static ResolvedPeriod Preset(PeriodPreset preset, DateTimeOffset? now = null, TimeZoneInfo? zone = null) => + PeriodResolver.Resolve(preset, null, null, now ?? September19, zone ?? Berlin); + + private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset? now = null, TimeZoneInfo? zone = null) => + PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now ?? September19, zone ?? Berlin); + + private static void AssertTiles(ResolvedPeriod period, IReadOnlyList buckets) + { + Assert.NotEmpty(buckets); + Assert.Equal(period.From, buckets[0].From); + Assert.Equal(period.To, buckets[^1].To); + Assert.Equal(period.FirstDay, buckets[0].FirstDay); + for (var i = 1; i < buckets.Count; i++) + { + Assert.Equal(buckets[i - 1].To, buckets[i].From); + Assert.Equal(buckets[i - 1].EndDay, buckets[i].FirstDay); + } + } + + [Fact] + public void The_last_12_months_give_exactly_12_month_buckets_the_last_one_ending_now() + { + var period = Preset(PeriodPreset.Last12Months); + + var plan = BucketPlanner.Plan(period, BucketSize.Month); + + Assert.False(plan.Refused); + Assert.Equal(12, plan.Buckets.Count); + Assert.Equal(12, plan.PointCount); + AssertTiles(period, plan.Buckets); + Assert.Equal( + ["2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07", "2026-08", "2026-09"], + plan.Buckets.Select(b => $"{b.FirstDay.Year:D4}-{b.FirstDay.Month:D2}")); + + var october = plan.Buckets[0]; + Assert.Equal((Day(2025, 10, 1), Day(2025, 11, 1)), (october.FirstDay, october.EndDay)); + Assert.Equal((Utc(2025, 9, 30, 22), Utc(2025, 10, 31, 23)), (october.From, october.To)); + + var september = plan.Buckets[^1]; + Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20)), (september.FirstDay, september.EndDay)); + Assert.Equal(September19, september.To); + Assert.All(plan.Buckets, b => Assert.Equal(BucketSize.Month, b.Size)); + } + + [Fact] + public void Across_New_Year_the_last_12_months_still_give_12_buckets_ending_with_the_new_January() + { + var period = Preset(PeriodPreset.Last12Months, At(Berlin, 2027, 1, 1, 0, 30)); + + var plan = BucketPlanner.Plan(period, BucketSize.Month); + + Assert.Equal(12, plan.Buckets.Count); + Assert.Equal(Day(2026, 2, 1), plan.Buckets[0].FirstDay); + Assert.Equal(Day(2027, 1, 1), plan.Buckets[^1].FirstDay); + Assert.Equal(TimeSpan.FromMinutes(30), plan.Buckets[^1].To - plan.Buckets[^1].From); + } + + [Fact] + public void At_the_exact_midnight_that_starts_a_month_the_last_12_months_keep_12_buckets_the_new_one_empty() + { + var period = Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 10, 1)); + + var plan = BucketPlanner.Plan(period, BucketSize.Month); + + Assert.Equal(12, plan.Buckets.Count); + Assert.Equal(Day(2026, 10, 1), plan.Buckets[^1].FirstDay); + Assert.Equal(plan.Buckets[^1].From, plan.Buckets[^1].To); + AssertTiles(period, plan.Buckets); + } + + [Theory] + [InlineData(BucketSize.Day)] + [InlineData(BucketSize.Week)] + [InlineData(BucketSize.Month)] + [InlineData(BucketSize.Auto)] + public void At_the_exact_midnight_that_starts_a_month_month_to_date_has_one_empty_bucket_like_the_last_12_months(BucketSize size) + { + var now = At(Berlin, 2026, 10, 1); + var monthToDate = Preset(PeriodPreset.MonthToDate, now); + var last12 = Preset(PeriodPreset.Last12Months, now); + + var plan = BucketPlanner.Plan(monthToDate, size); + + // Both presets agree that October exists and is empty: no "not yet occurred" for one and an empty + // bucket for the other. + var today = Assert.Single(plan.Buckets); + Assert.Equal((Day(2026, 10, 1), now, now), (today.FirstDay, today.From, today.To)); + Assert.Equal(1, plan.PointCount); + Assert.Equal(last12.To, BucketPlanner.Plan(last12, BucketSize.Month).Buckets[^1].To); + AssertTiles(monthToDate, plan.Buckets); + } + + [Fact] + public void Week_buckets_start_on_Monday_and_the_first_one_starts_with_the_period() + { + // 1 September 2026 is a Tuesday. + var period = Preset(PeriodPreset.MonthToDate); + + var plan = BucketPlanner.Plan(period, BucketSize.Week); + + Assert.Equal( + [(Day(2026, 9, 1), Day(2026, 9, 7)), (Day(2026, 9, 7), Day(2026, 9, 14)), (Day(2026, 9, 14), Day(2026, 9, 20))], + plan.Buckets.Select(b => (b.FirstDay, b.EndDay))); + Assert.Equal(DayOfWeek.Tuesday, plan.Buckets[0].FirstDay.DayOfWeek); + Assert.All(plan.Buckets.Skip(1), b => Assert.Equal(DayOfWeek.Monday, b.FirstDay.DayOfWeek)); + Assert.Equal(September19, plan.Buckets[^1].To); + AssertTiles(period, plan.Buckets); + } + + [Fact] + public void A_range_from_Monday_to_Sunday_is_whole_weeks() + { + var period = Custom(Day(2026, 9, 7), Day(2026, 9, 20), At(Berlin, 2026, 10, 1, 12, 0)); + + var plan = BucketPlanner.Plan(period, BucketSize.Week); + + Assert.Equal(2, plan.Buckets.Count); + Assert.All(plan.Buckets, b => Assert.Equal(7, b.EndDay.DayNumber - b.FirstDay.DayNumber)); + Assert.Equal(Utc(2026, 9, 20, 22), plan.Buckets[^1].To); + } + + [Fact] + public void Day_buckets_follow_local_midnight_so_the_spring_DST_day_has_23_hours() + { + var march = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 4, 2, 9, 0)); + + var plan = BucketPlanner.Plan(march, BucketSize.Day); + + Assert.Equal(31, plan.Buckets.Count); + var dstDay = plan.Buckets.Single(b => b.FirstDay == Day(2026, 3, 29)); + Assert.Equal((Utc(2026, 3, 28, 23), Utc(2026, 3, 29, 22)), (dstDay.From, dstDay.To)); + Assert.Equal(TimeSpan.FromHours(23), dstDay.To - dstDay.From); + Assert.All(plan.Buckets.Where(b => b != dstDay), b => Assert.Equal(TimeSpan.FromHours(24), b.To - b.From)); + AssertTiles(march, plan.Buckets); + } + + [Fact] + public void Day_buckets_follow_local_midnight_so_the_autumn_DST_day_has_25_hours() + { + var october = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 11, 2, 9, 0)); + + var plan = BucketPlanner.Plan(october, BucketSize.Day); + + var dstDay = plan.Buckets.Single(b => b.FirstDay == Day(2026, 10, 25)); + Assert.Equal((Utc(2026, 10, 24, 22), Utc(2026, 10, 25, 23)), (dstDay.From, dstDay.To)); + Assert.Equal(TimeSpan.FromHours(25), dstDay.To - dstDay.From); + } + + [Fact] + public void Behind_UTC_month_buckets_start_at_New_York_midnight() + { + var period = Custom(Day(2026, 10, 1), Day(2026, 11, 30), zone: NewYork, now: At(NewYork, 2026, 12, 15, 12, 0)); + + var plan = BucketPlanner.Plan(period, BucketSize.Month); + + Assert.Equal( + [(Utc(2026, 10, 1, 4), Utc(2026, 11, 1, 4)), (Utc(2026, 11, 1, 4), Utc(2026, 12, 1, 5))], + plan.Buckets.Select(b => (b.From, b.To))); + } + + [Fact] + public void A_custom_range_past_now_gets_buckets_only_up_to_now() + { + var period = Custom(Day(2026, 9, 1), Day(2026, 12, 31)); + + var plan = BucketPlanner.Plan(period, BucketSize.Month); + + var only = Assert.Single(plan.Buckets); + Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20)), (only.FirstDay, only.EndDay)); + Assert.Equal(September19, only.To); + } + + [Fact] + public void A_period_that_has_not_started_or_has_no_history_has_no_buckets() + { + var future = Custom(Day(2027, 1, 1), Day(2027, 3, 31)); + var none = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin); + + foreach (var period in new[] { future, none }) + { + foreach (var size in new[] { BucketSize.Auto, BucketSize.Day, BucketSize.Year }) + { + var plan = BucketPlanner.Plan(period, size); + Assert.Empty(plan.Buckets); + Assert.False(plan.Refused); + Assert.Equal(0, plan.PointCount); + } + } + } + + [Theory] + [InlineData(PeriodPreset.MonthToDate, BucketSize.Day, 19)] + [InlineData(PeriodPreset.LastMonth, BucketSize.Day, 31)] + [InlineData(PeriodPreset.YearToDate, BucketSize.Month, 9)] + [InlineData(PeriodPreset.Last12Months, BucketSize.Month, 12)] + [InlineData(PeriodPreset.Last24Months, BucketSize.Month, 24)] + [InlineData(PeriodPreset.PreviousYear, BucketSize.Month, 12)] + public void Auto_picks_days_for_short_ranges_and_months_for_a_year_or_more(PeriodPreset preset, BucketSize expected, int points) + { + var plan = BucketPlanner.Plan(Preset(preset), BucketSize.Auto); + + Assert.Equal(BucketSize.Auto, plan.Requested); + Assert.Equal(expected, plan.Size); + Assert.Equal(points, plan.Buckets.Count); + Assert.False(plan.Refused); + } + + [Theory] + [InlineData(62, BucketSize.Day)] + [InlineData(63, BucketSize.Week)] + [InlineData(182, BucketSize.Week)] + [InlineData(183, BucketSize.Month)] + public void Auto_switches_from_days_to_weeks_after_62_days_and_to_months_after_26_weeks(int days, BucketSize expected) + { + var first = Day(2025, 1, 1); + var period = Custom(first, first.AddDays(days - 1)); + + Assert.Equal(expected, BucketPlanner.Plan(period, BucketSize.Auto).Size); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(2, 15)] + [InlineData(3, 2)] + [InlineData(6, 15)] + [InlineData(7, 2)] + [InlineData(9, 19)] + [InlineData(12, 31)] + public void Auto_charts_year_to_date_by_month_on_every_day_of_the_year(int month, int day) + { + // Chosen from the named year, not the elapsed part (A-06): the same URL used to render by day until + // 2 March and by week until 1 July. + var period = Preset(PeriodPreset.YearToDate, At(Berlin, 2026, month, day, 12, 0)); + + var plan = BucketPlanner.Plan(period, BucketSize.Auto); + + Assert.Equal(BucketSize.Month, plan.Size); + Assert.Equal(month, plan.Buckets.Count); + } + + [Fact] + public void Auto_charts_month_to_date_by_day_even_on_the_1st() + { + var plan = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 9, 1, 8, 0)), BucketSize.Auto); + + Assert.Equal(BucketSize.Day, plan.Size); + Assert.Single(plan.Buckets); + } + + [Fact] + public void Auto_sizes_a_custom_range_reaching_past_now_by_the_range_asked_for() + { + var first = Day(2026, 9, 1); + var last = Day(2026, 12, 31); + + var inSeptember = BucketPlanner.Plan(Custom(first, last, At(Berlin, 2026, 9, 19, 12, 0)), BucketSize.Auto); + var inNovember = BucketPlanner.Plan(Custom(first, last, At(Berlin, 2026, 11, 20, 12, 0)), BucketSize.Auto); + + Assert.Equal(BucketSize.Week, inSeptember.Size); + Assert.Equal(BucketSize.Week, inNovember.Size); + } + + [Fact] + public void Auto_checks_the_point_limit_on_the_buckets_that_exist_up_to_now() + { + // The named year has 12 months, over a limit of 9; only the 9 that exist by 19 September are counted. + var plan = BucketPlanner.Plan(Preset(PeriodPreset.YearToDate), BucketSize.Auto, maxPoints: 9); + + Assert.Equal(BucketSize.Month, plan.Size); + Assert.Equal(9, plan.PointCount); + Assert.False(plan.Refused); + } + + [Fact] + public void Auto_weighs_months_against_the_point_limit_it_was_given() + { + // 501 months since 1985: over the default 400, within a limit of 1,000. + var since1985 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1985, 1, 1)); + + var generous = BucketPlanner.Plan(since1985, BucketSize.Auto, maxPoints: 1_000); + var standard = BucketPlanner.Plan(since1985, BucketSize.Auto); + + Assert.Equal(BucketSize.Month, generous.Size); + Assert.Equal(501, generous.Buckets.Count); + Assert.Equal(BucketSize.Year, standard.Size); + } + + [Fact] + public void Auto_charts_all_history_from_a_stray_ancient_reading_by_year_instead_of_refusing() + { + var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(206, 5, 1)); + + var plan = BucketPlanner.Plan(period, BucketSize.Auto); + + Assert.False(plan.Refused); + Assert.Equal(BucketSize.Year, plan.Size); + Assert.Equal(2026 - 1900 + 1, plan.Buckets.Count); + } + + [Fact] + public void Auto_uses_months_up_to_400_of_them_and_years_beyond() + { + // The reference data's oil history starts in 1997: 357 months to September 2026. + var since1997 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1997, 1, 1)); + var since1900 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1900, 1, 1)); + + var monthly = BucketPlanner.Plan(since1997, BucketSize.Auto); + var yearly = BucketPlanner.Plan(since1900, BucketSize.Auto); + + Assert.Equal(BucketSize.Month, monthly.Size); + Assert.Equal(357, monthly.Buckets.Count); + Assert.Equal(BucketSize.Year, yearly.Size); + Assert.Equal(127, yearly.Buckets.Count); + } + + [Fact] + public void Auto_never_goes_finer_than_the_coarsest_resolution_a_series_needs() + { + var monthToDate = Preset(PeriodPreset.MonthToDate); + + // A monthly import in a daily chart would be nothing but unresolved buckets. + var monthly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Month); + var weekly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Week); + var coarse = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Coarse); + + var month = Assert.Single(monthly.Buckets); + Assert.Equal((Utc(2026, 8, 31, 22), September19), (month.From, month.To)); + Assert.Equal(BucketSize.Week, weekly.Size); + Assert.Equal(BucketSize.Year, coarse.Size); + Assert.Equal([Day(2025, 10, 1), Day(2026, 1, 1)], coarse.Buckets.Select(b => b.FirstDay)); + } + + [Fact] + public void Auto_keeps_the_length_based_default_when_the_data_is_finer() + { + // Hourly data does not turn a year into 365 bars; the user can still ask for days explicitly. + var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Hour); + + Assert.Equal(BucketSize.Month, plan.Size); + } + + [Fact] + public void Auto_coarsens_until_the_plan_fits_a_smaller_point_limit() + { + var plan = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Auto, maxPoints: 10); + + Assert.Equal(BucketSize.Week, plan.Size); + Assert.Equal(3, plan.Buckets.Count); + Assert.False(plan.Refused); + } + + [Fact] + public void An_explicit_day_bucket_over_400_points_is_refused_with_weeks_suggested() + { + var period = Preset(PeriodPreset.Last24Months); + + var plan = BucketPlanner.Plan(period, BucketSize.Day); + + Assert.True(plan.Refused); + Assert.Empty(plan.Buckets); + Assert.Equal(BucketSize.Day, plan.Size); + Assert.Equal(Day(2026, 9, 19).DayNumber - Day(2024, 10, 1).DayNumber + 1, plan.PointCount); + Assert.Equal(BucketSize.Week, plan.Suggested); + Assert.False(BucketPlanner.Plan(period, BucketSize.Week).Refused); + } + + [Fact] + public void A_refusal_suggests_nothing_finer_than_the_data_resolves() + { + var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last24Months), BucketSize.Day, ResolutionClass.Month); + + Assert.True(plan.Refused); + Assert.Equal(BucketSize.Month, plan.Suggested); + } + + [Fact] + public void A_refusal_over_centuries_suggests_years() + { + var period = Custom(Day(1950, 1, 1), Day(2025, 12, 31)); + + var plan = BucketPlanner.Plan(period, BucketSize.Month); + + Assert.True(plan.Refused); + Assert.Equal(76 * 12, plan.PointCount); + Assert.Equal(BucketSize.Year, plan.Suggested); + } + + [Fact] + public void An_explicit_size_within_the_limit_is_honoured_even_finer_than_the_data() + { + var period = Preset(PeriodPreset.Last12Months); + + var plan = BucketPlanner.Plan(period, BucketSize.Day, ResolutionClass.Month); + + Assert.False(plan.Refused); + Assert.Equal(Day(2026, 9, 19).DayNumber - Day(2025, 10, 1).DayNumber + 1, plan.Buckets.Count); + AssertTiles(period, plan.Buckets); + } + + [Fact] + public void Every_size_counts_its_buckets_without_building_them() + { + var periods = new[] + { + Preset(PeriodPreset.MonthToDate), + Preset(PeriodPreset.Last24Months), + Preset(PeriodPreset.PreviousYear), + Custom(Day(2023, 12, 31), Day(2025, 1, 1)), + Preset(PeriodPreset.YearToDate, At(NewYork, 2026, 3, 8, 12, 0), NewYork), + }; + + foreach (var period in periods) + { + foreach (var size in new[] { BucketSize.Day, BucketSize.Week, BucketSize.Month, BucketSize.Year }) + { + var plan = BucketPlanner.Plan(period, size, maxPoints: 10_000); + Assert.Equal(BucketPlanner.CountBuckets(period, size), plan.Buckets.Count); + AssertTiles(period, plan.Buckets); + } + } + } + + [Fact] + public void The_current_month_bucket_is_cut_short_at_now_and_names_the_whole_month_to_drill_into() + { + // Drilling into "1 – 19 Sep" as a custom range would compare with 13 – 31 August; the whole month + // compares with 1 – 19 August, like month to date (D-51). + foreach (var preset in new[] { PeriodPreset.MonthToDate, PeriodPreset.YearToDate, PeriodPreset.Last12Months }) + { + var plan = BucketPlanner.Plan(Preset(preset), BucketSize.Month); + + var september = plan.Buckets[^1]; + Assert.Equal((Day(2026, 9, 20), Day(2026, 10, 1)), (september.EndDay, september.NominalEndDay)); + Assert.True(september.IsCutShort); + Assert.All(plan.Buckets.SkipLast(1), b => Assert.False(b.IsCutShort)); + Assert.All(plan.Buckets.SkipLast(1), b => Assert.Null(b.NominalEndDay)); + } + } + + [Fact] + public void A_week_cut_at_now_names_its_Sunday_and_a_day_is_never_cut_short() + { + var weeks = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Week); + var days = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Day); + + Assert.Equal(Day(2026, 9, 21), weeks.Buckets[^1].NominalEndDay); + Assert.All(days.Buckets, b => Assert.False(b.IsCutShort)); + } + + [Fact] + public void The_whole_unit_of_a_cut_bucket_never_reaches_past_the_range_that_was_asked_for() + { + var reachingPastNow = BucketPlanner.Plan(Custom(Day(2026, 9, 1), Day(2026, 9, 25)), BucketSize.Month); + var complete = BucketPlanner.Plan(Custom(Day(2026, 8, 10), Day(2026, 9, 15), At(Berlin, 2026, 10, 1, 12, 0)), BucketSize.Month); + + Assert.Equal(Day(2026, 9, 26), Assert.Single(reachingPastNow.Buckets).NominalEndDay); + + // A range that ends mid-month by request is not cut short: its last bucket is all it names. + Assert.All(complete.Buckets, b => Assert.False(b.IsCutShort)); + Assert.Equal(Day(2026, 9, 16), complete.Buckets[^1].EndDay); + } + + [Fact] + public void At_midnight_the_empty_current_month_still_names_the_whole_month() + { + var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 10, 1)), BucketSize.Month); + + Assert.Equal((Day(2026, 10, 2), Day(2026, 11, 1)), (plan.Buckets[^1].EndDay, plan.Buckets[^1].NominalEndDay)); + } + + [Fact] + public void The_point_limit_must_allow_at_least_one_point() + { + Assert.Throws(() => BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Day, maxPoints: 0)); + } + + [Theory] + [InlineData(ResolutionClass.Hour, BucketSize.Day)] + [InlineData(ResolutionClass.Day, BucketSize.Day)] + [InlineData(ResolutionClass.Week, BucketSize.Week)] + [InlineData(ResolutionClass.Month, BucketSize.Month)] + [InlineData(ResolutionClass.Coarse, BucketSize.Year)] + public void Each_resolution_class_maps_to_the_finest_bucket_it_can_fill(ResolutionClass resolution, BucketSize expected) + { + Assert.Equal(expected, BucketPlanner.MinimumSizeFor(resolution)); + } +} diff --git a/tests/Core.Tests/Analysis/CategoryCoverTests.cs b/tests/Core.Tests/Analysis/CategoryCoverTests.cs new file mode 100644 index 0000000..35f7dca --- /dev/null +++ b/tests/Core.Tests/Analysis/CategoryCoverTests.cs @@ -0,0 +1,319 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using static MeterVault.Core.Tests.Analysis.TotalsSeed; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// D-42: a category's cost is the bill algorithm run on its members, and only categories that are slices of the +/// bill form the composition. The seed pins both halves — the Strom category bills Netz (the sheet's Kosten), a +/// car-only category bills the car as a view, and the tank, which is billed but in no category, is Uncategorized. +/// +public sealed class CategoryCoverTests +{ + private const int Strom = 101; + private const int WasserCategory = 102; + private const int Heizung = 103; + private const int EAuto = 104; + + [Fact] + public void The_seeded_Strom_category_bills_only_Netz() + { + var full = TotalsPolicy.Classify(Meters(), Links()); + + var cover = CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]); + + Assert.Equal([Netz], cover.BilledMeterIds); + Assert.Empty(cover.SeparatelyBilled); + Assert.Empty(cover.FeedInMeterIds); + Assert.Equal([Haus, Auto, Solar1, Solar2], cover.AnalysisOnlyMeterIds); + Assert.False(cover.LiesOutsideBill); + Assert.Equal(MeterTotalsClass.Breakdown, cover.Meters[Auto].Class); + } + + [Fact] + public void A_category_holding_only_Auto_bills_Auto_as_an_overlapping_view() + { + var full = TotalsPolicy.Classify(Meters(), Links()); + + var cover = CategoryCover.Compute(full, EAuto, [Auto]); + + Assert.Equal([Auto], cover.BilledMeterIds); + Assert.Equal(MeterTotalsClass.Use, cover.Meters[Auto].Class); + Assert.Equal([Auto], cover.OutsideBillMeterIds); + Assert.True(cover.LiesOutsideBill); + } + + [Fact] + public void A_category_holding_the_house_but_not_the_grid_prices_household_use_as_a_view() + { + var full = TotalsPolicy.Classify(Meters(), Links()); + + var cover = CategoryCover.Compute(full, 105, [Haus, Auto]); + + Assert.Equal([Haus], cover.BilledMeterIds); + Assert.True(cover.LiesOutsideBill); + } + + [Fact] + public void An_expanded_heating_oil_type_member_bills_the_tank_and_not_the_burner() + { + var full = TotalsPolicy.Classify(Meters(), Links()); + + var cover = CategoryCover.Compute(full, Heizung, [Oeltank, Brenner]); + + Assert.Equal([Oeltank], cover.BilledMeterIds); + Assert.Equal([Brenner], cover.AnalysisOnlyMeterIds); + Assert.False(cover.LiesOutsideBill); + } + + [Fact] + public void Seeded_categories_split_into_slices_views_and_the_uncategorized_tank() + { + var full = TotalsPolicy.Classify(Meters(), Links()); + CategoryCoverResult[] covers = + [ + CategoryCover.Compute(full, Heizung, []), + CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]), + CategoryCover.Compute(full, WasserCategory, [Wasser]), + CategoryCover.Compute(full, EAuto, [Auto]), + ]; + + var report = CategoryCover.CheckOverlap(full, covers); + + Assert.Equal([Heizung, Strom, WasserCategory], report.DisjointCategoryIds); + Assert.Equal([EAuto], report.OverlappingViewIds); + Assert.Empty(report.Overlaps); + Assert.Equal([Oeltank], report.UncategorizedMeterIds); + } + + [Fact] + public void Two_categories_billing_the_same_meter_are_both_overlapping_views() + { + var full = TotalsPolicy.Classify(Meters(), Links()); + CategoryCoverResult[] covers = + [ + CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]), + CategoryCover.Compute(full, 106, [Netz]), + CategoryCover.Compute(full, WasserCategory, [Wasser]), + ]; + + var report = CategoryCover.CheckOverlap(full, covers); + + Assert.Equal([new CategoryOverlap(Strom, 106, [Netz])], report.Overlaps, OverlapComparer.Instance); + Assert.Equal([Strom, 106], report.OverlappingViewIds); + Assert.Equal([WasserCategory], report.DisjointCategoryIds); + Assert.Equal([Netz, Oeltank], report.UncategorizedMeterIds); + } + + [Fact] + public void A_category_keeps_containment_through_a_meter_it_left_out() + { + // Wasser → Keller → Garten; a category of Wasser and Garten must not bill the garden on top of the main + // meter just because the basement meter between them is not a member. + var meters = Meters(); + meters.Add(Physical(20, "Keller", Water, MeterMode.CumulativeCounter, "m³")); + meters.Add(Physical(21, "Garten", Water, MeterMode.CumulativeCounter, "m³")); + var full = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20), new(20, 21)]); + + var cover = CategoryCover.Compute(full, WasserCategory, [Wasser, 21]); + + Assert.Equal([Wasser], cover.BilledMeterIds); + Assert.Equal([Wasser], cover.Meters[21].ParentIds); + Assert.Equal([21], cover.AnalysisOnlyMeterIds); + } + + [Fact] + public void A_separately_billed_heat_pump_stays_billed_in_a_category_without_the_house() + { + var meters = Meters(); + meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); + var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10); + + var withGrid = CategoryCover.Compute(full, Strom, [Netz, 10]); + var heatPumpOnly = CategoryCover.Compute(full, 107, [10]); + + Assert.Equal([Netz], withGrid.BilledMeterIds); + Assert.Equal([new SeparatelyBilledMeter(10, Netz, Haus)], withGrid.SeparatelyBilled); + Assert.False(withGrid.LiesOutsideBill); + Assert.Equal([10], heatPumpOnly.CoverMeterIds); + Assert.False(heatPumpOnly.LiesOutsideBill); + + // Alone in its category the heat pump is a root of that restricted run, but the bill prices it at its own + // tariff — and so must the category, or its slice would be charged at the grid price. + Assert.Empty(heatPumpOnly.BilledMeterIds); + Assert.Equal([new SeparatelyBilledMeter(10, Netz, Haus)], heatPumpOnly.SeparatelyBilled); + Assert.Equal(BillLineKind.OwnPrice, Assert.Single(heatPumpOnly.Lines).Kind); + } + + [Fact] + public void A_strom_slice_prices_the_grid_import_with_the_heat_pump_deducted_as_the_bill_does() + { + var meters = Meters(); + meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); + var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10); + + var strom = CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]); + + var line = Assert.Single(strom.Lines); + Assert.Equal(full.LineOf(Netz), line); + Assert.Equal([new BillDeduction(10, 1)], line.Deductions); + } + + [Fact] + public void Strom_plus_uncategorized_reconciles_to_the_bill_when_a_heat_pump_is_billed_separately() + { + // The probed trap: pricing Strom's BilledMeterIds as returned books all of Netz, the heat pump is booked again + // as Uncategorized, and the composition exceeds the bill by 100 kWh × 0.30. Priced by lines it reconciles. + var meters = Meters(); + meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); + var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10); + CategoryCoverResult[] covers = + [ + CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]), + CategoryCover.Compute(full, WasserCategory, [Wasser]), + CategoryCover.Compute(full, Heizung, [Oeltank, Brenner]), + ]; + + var report = CategoryCover.CheckOverlap(full, covers); + + Assert.Equal([Strom, WasserCategory, Heizung], report.DisjointCategoryIds); + Assert.Equal([10], report.UncategorizedMeterIds); + var bill = full.Types.Values.Sum(t => Cost(t.Billing.Lines, meters)); + var composition = covers.Sum(c => Cost(c.Lines, meters)) + + Cost(report.UncategorizedMeterIds.Select(id => full.LineOf(id)!), meters); + Assert.Equal((300 - 100) * 0.30 + 100 * 0.22 + 10 * 5.0 + 200 * 1.10, bill, 9); + Assert.Equal(bill, composition, 9); + } + + [Fact] + public void A_heat_pump_category_beside_strom_completes_the_composition_with_nothing_uncategorized() + { + var meters = Meters(); + meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); + var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10); + CategoryCoverResult[] covers = + [ + CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]), + CategoryCover.Compute(full, 107, [10]), + CategoryCover.Compute(full, WasserCategory, [Wasser]), + CategoryCover.Compute(full, Heizung, [Oeltank, Brenner]), + ]; + + var report = CategoryCover.CheckOverlap(full, covers); + + Assert.Empty(report.UncategorizedMeterIds); + Assert.Empty(report.OverlappingViewIds); + Assert.Equal(full.Types.Values.Sum(t => Cost(t.Billing.Lines, meters)), covers.Sum(c => Cost(c.Lines, meters)), 9); + } + + [Fact] + public void A_view_outside_the_bill_is_priced_as_its_own_restricted_bill() + { + // House and heat pump without the grid meter: household use is billed here, with the heat pump out of it. + var meters = Meters(); + meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); + var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10); + + var view = CategoryCover.Compute(full, 109, [Haus, 10]); + + Assert.True(view.LiesOutsideBill); + Assert.Equal([Haus], view.BilledMeterIds); + Assert.Equal([new SeparatelyBilledMeter(10, Haus, null)], view.SeparatelyBilled); + Assert.Equal([new BillDeduction(10, 1)], view.Lines[0].Deductions); + } + + [Fact] + public void A_strom_category_without_the_heat_pump_leaves_the_heat_pump_uncategorized() + { + var meters = Meters(); + meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); + var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10); + CategoryCoverResult[] covers = + [ + CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]), + CategoryCover.Compute(full, WasserCategory, [Wasser]), + ]; + + var report = CategoryCover.CheckOverlap(full, covers); + + Assert.Equal([Strom, WasserCategory], report.DisjointCategoryIds); + Assert.Equal([Oeltank, 10], report.UncategorizedMeterIds); + } + + [Fact] + public void An_export_member_brings_its_feed_in_credit_into_the_category() + { + List meters = + [ + Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport), + Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridExport), + Physical(22, "PV", Electricity, MeterMode.GenerationCounter, "kWh"), + ]; + var full = TotalsPolicy.Classify(meters, []); + + var cover = CategoryCover.Compute(full, Strom, [20, 21, 22]); + + Assert.Equal([20], cover.BilledMeterIds); + Assert.Equal([21], cover.FeedInMeterIds); + Assert.Equal([22], cover.AnalysisOnlyMeterIds); + Assert.Equal([20, 21], cover.CoverMeterIds); + Assert.False(cover.LiesOutsideBill); + } + + [Fact] + public void A_category_counting_Summe_Solar_instead_of_its_strings_bills_nothing_extra() + { + var full = TotalsPolicy.Classify(MetersWithOverride((SummeSolar, TotalsOverride.Always)), Links()); + + var cover = CategoryCover.Compute(full, 108, [Solar1, Solar2, SummeSolar]); + + Assert.Empty(cover.CoverMeterIds); + Assert.Equal(MeterTotalsClass.IncludedByOverride, cover.Meters[SummeSolar].Class); + Assert.Equal(MeterTotalsClass.ExcludedByOverride, cover.Meters[Solar1].Class); + } + + [Fact] + public void Unknown_member_ids_are_ignored() + { + var full = TotalsPolicy.Classify(Meters(), Links()); + + var cover = CategoryCover.Compute(full, WasserCategory, [Wasser, 404, Wasser]); + + Assert.Equal([Wasser], cover.MemberIds); + Assert.Equal([Wasser], cover.BilledMeterIds); + } + + /// + /// One month priced the way costing prices lines: electricity 0.30/kWh, the heat pump's own 0.22/kWh, water + /// 5.00/m³, oil 1.10/L; quantities Haus 500, Netz 300, Auto 50, heat pump 100, Wasser 10, Öltank 200 (the others + /// are never on a line). + /// + private static double Cost(IEnumerable lines, IReadOnlyList meters) + { + var quantity = new Dictionary { [Haus] = 500, [Netz] = 300, [Auto] = 50, [10] = 100, [Wasser] = 10, [Oeltank] = 200 }; + var typePrice = new Dictionary { [Electricity] = 0.30, [Water] = 5.0, [Oil] = 1.10 }; + var ownPrice = new Dictionary { [10] = 0.22 }; + return lines.Sum(line => + { + var priced = quantity[line.MeterId] - line.Deductions.Sum(d => d.UnitFactor * quantity[d.MeterId]); + var price = line.Kind == BillLineKind.OwnPrice + ? ownPrice[line.MeterId] + : typePrice[meters.Single(m => m.Id == line.MeterId).EnergyTypeId]; + return priced * price; + }); + } + + private sealed class OverlapComparer : IEqualityComparer + { + public static readonly OverlapComparer Instance = new(); + + public bool Equals(CategoryOverlap? x, CategoryOverlap? y) => + x is not null && y is not null + && x.CategoryId == y.CategoryId && x.OtherCategoryId == y.OtherCategoryId + && x.SharedMeterIds.SequenceEqual(y.SharedMeterIds); + + public int GetHashCode(CategoryOverlap obj) => HashCode.Combine(obj.CategoryId, obj.OtherCategoryId); + } +} diff --git a/tests/Core.Tests/Analysis/ChangeTests.cs b/tests/Core.Tests/Analysis/ChangeTests.cs new file mode 100644 index 0000000..a4ab044 --- /dev/null +++ b/tests/Core.Tests/Analysis/ChangeTests.cs @@ -0,0 +1,145 @@ +using MeterVault.Core.Analysis; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// One rule for change figures everywhere (D-08): the absolute difference whenever both values are known, +/// a percentage only against a positive baseline, and "unknown" never read as zero. +/// +public sealed class ChangeTests +{ + [Fact] + public void A_rise_from_a_positive_baseline_has_an_absolute_difference_a_percentage_and_an_upward_direction() + { + var change = Change.Between(120, 100); + + Assert.Equal(20, change.Absolute!.Value, 9); + Assert.Equal(20, change.Percent!.Value, 9); + Assert.Equal(1, change.Direction); + Assert.True(change.IsAvailable); + Assert.True(change.PercentApplicable); + } + + [Fact] + public void A_fall_is_negative_in_both_figures() + { + var change = Change.Between(75, 100); + + Assert.Equal(-25, change.Absolute!.Value, 9); + Assert.Equal(-25, change.Percent!.Value, 9); + Assert.Equal(-1, change.Direction); + } + + [Fact] + public void Against_a_zero_baseline_the_percentage_is_not_applicable_but_the_difference_is_shown() + { + // The old dashboard chip read "+0.0 %" here. + var change = Change.Between(120, 0); + + Assert.Equal(120, change.Absolute!.Value, 9); + Assert.Null(change.Percent); + Assert.False(change.PercentApplicable); + Assert.Equal(1, change.Direction); + } + + [Fact] + public void Against_a_negative_baseline_the_percentage_is_not_applicable() + { + // A credit shrinking from -10 to -5 is a rise of 5; dividing by -10 would call it -50 %. + var change = Change.Between(-5, -10); + + Assert.Equal(5, change.Absolute!.Value, 9); + Assert.Null(change.Percent); + Assert.Equal(1, change.Direction); + } + + [Fact] + public void A_signed_change_from_positive_to_negative_keeps_its_percentage() + { + var change = Change.Between(-50, 100); + + Assert.Equal(-150, change.Absolute!.Value, 9); + Assert.Equal(-150, change.Percent!.Value, 9); + Assert.Equal(-1, change.Direction); + } + + [Fact] + public void Equal_values_are_no_change_even_after_floating_point_noise() + { + var exact = Change.Between(100, 100); + var noisy = Change.Between(0.1 + 0.2, 0.3); + + Assert.Equal(0, exact.Direction); + Assert.Equal(0, exact.Percent!.Value, 9); + Assert.Equal(0, noisy.Direction); + Assert.NotNull(noisy.Absolute); + } + + [Fact] + public void A_baseline_within_the_tolerance_of_zero_counts_as_zero() + { + Assert.Null(Change.Between(5, 1e-12).Percent); + } + + [Theory] + [InlineData(null, 100.0)] + [InlineData(100.0, null)] + [InlineData(null, null)] + [InlineData(double.NaN, 100.0)] + [InlineData(100.0, double.PositiveInfinity)] + public void A_missing_or_non_finite_value_makes_the_change_unavailable_not_minus_100_percent(double? current, double? previous) + { + var change = Change.Between(current, previous); + + Assert.Equal(Change.Unavailable, change); + Assert.Null(change.Absolute); + Assert.Null(change.Percent); + Assert.Equal(0, change.Direction); + Assert.False(change.IsAvailable); + } + + [Fact] + public void A_difference_that_overflows_is_unavailable() + { + Assert.Equal(Change.Unavailable, Change.Between(double.MaxValue, -double.MaxValue)); + } + + [Fact] + public void A_difference_below_the_callers_display_tolerance_has_no_direction_but_keeps_its_exact_value() + { + // 0.004 € shows as "+0.00 €"; an upward arrow beside it would claim a change nobody can see. + var cents = Change.Between(100.004, 100, tolerance: 0.005); + var noiseOnly = Change.Between(100.004, 100); + + Assert.Equal(0, cents.Direction); + Assert.Equal(0.004, cents.Absolute!.Value, 9); + Assert.NotNull(cents.Percent); + Assert.Equal(1, noiseOnly.Direction); + } + + [Fact] + public void A_baseline_that_displays_as_zero_under_the_callers_tolerance_has_no_percentage() + { + Assert.Null(Change.Between(5, 0.004, tolerance: 0.005).Percent); + Assert.NotNull(Change.Between(5, 0.004).Percent); + } + + [Theory] + [InlineData(-0.001)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void A_negative_or_non_finite_tolerance_is_rejected(double tolerance) + { + Assert.Throws(() => Change.Between(1, 2, tolerance)); + } + + [Fact] + public void An_observed_zero_now_against_a_positive_baseline_is_a_real_minus_100_percent() + { + var change = Change.Between(0, 80); + + Assert.Equal(-80, change.Absolute!.Value, 9); + Assert.Equal(-100, change.Percent!.Value, 9); + Assert.Equal(-1, change.Direction); + } +} diff --git a/tests/Core.Tests/Analysis/ComparisonResolverTests.cs b/tests/Core.Tests/Analysis/ComparisonResolverTests.cs new file mode 100644 index 0000000..b147a62 --- /dev/null +++ b/tests/Core.Tests/Analysis/ComparisonResolverTests.cs @@ -0,0 +1,832 @@ +using MeterVault.Core.Analysis; +using static MeterVault.Core.Tests.Analysis.AnalysisClock; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// Comparisons shift by local calendar units and map the to-date cut-off as a local date plus wall-clock +/// time (D-06). What these pin down: a month to date compares with the same elapsed part of the previous +/// month, 31 March with all of February, 29 February with all of the previous February; a cut-off in a DST +/// gap takes the first instant after it and one in a fold takes the occurrence with now's offset. The same +/// public mapping shifts any instant (D-07) and pairs the current buckets with the comparison's, and it never +/// runs backwards. +/// +public sealed class ComparisonResolverTests +{ + private static readonly DateTimeOffset September19 = At(Berlin, 2026, 9, 19, 14, 37); + + private static readonly ComparisonRequest PreviousPeriod = new(ComparisonKind.PreviousPeriod); + + private static readonly ComparisonRequest PreviousYear = new(ComparisonKind.PreviousYear); + + private static ResolvedPeriod Preset(PeriodPreset preset, DateTimeOffset? now = null, TimeZoneInfo? zone = null) => + PeriodResolver.Resolve(preset, null, null, now ?? September19, zone ?? Berlin); + + private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset? now = null) => + PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now ?? September19, Berlin); + + private static ComparisonPeriod Applicable(ResolvedPeriod current, ComparisonRequest request) + { + var resolution = ComparisonResolver.Resolve(current, request); + Assert.True(resolution.IsApplicable, $"not applicable: {resolution.Reason}"); + Assert.Equal(ComparisonUnavailableReason.None, resolution.Reason); + return resolution.Period; + } + + private static ComparisonUnavailableReason Reason(ResolvedPeriod current, ComparisonRequest request) + { + var resolution = ComparisonResolver.Resolve(current, request); + Assert.False(resolution.IsApplicable); + Assert.Null(resolution.Period); + return resolution.Reason; + } + + [Fact] + public void Month_to_date_compares_with_the_same_elapsed_part_of_the_previous_month() + { + var comparison = Applicable(Preset(PeriodPreset.MonthToDate), PreviousPeriod); + + Assert.Equal(Day(2026, 8, 1), comparison.FirstDay); + Assert.Equal(Day(2026, 8, 19), comparison.LastDay); + Assert.Equal(Day(2026, 8, 31), comparison.NominalLastDay); + Assert.Equal(Utc(2026, 7, 31, 22), comparison.From); + Assert.Equal(Utc(2026, 8, 19, 12, 37), comparison.To); + Assert.True(comparison.IsCutOff); + Assert.False(comparison.CappedAtNow); + Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 1), comparison.Shift); + Assert.Equal(ComparisonKind.PreviousPeriod, comparison.Kind); + } + + [Fact] + public void Month_to_date_against_the_previous_year_is_the_same_month_to_the_same_wall_clock_time() + { + var comparison = Applicable(Preset(PeriodPreset.MonthToDate), PreviousYear); + + Assert.Equal((Day(2025, 9, 1), Day(2025, 9, 19)), (comparison.FirstDay, comparison.LastDay)); + Assert.Equal((Utc(2025, 8, 31, 22), Utc(2025, 9, 19, 12, 37)), (comparison.From, comparison.To)); + Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, 1), comparison.Shift); + } + + [Fact] + public void Year_to_date_compares_with_the_previous_year_up_to_the_same_moment() + { + var current = Preset(PeriodPreset.YearToDate); + + var previousPeriod = Applicable(current, PreviousPeriod); + var previousYear = Applicable(current, PreviousYear); + var named = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2023)); + + Assert.Equal((Utc(2024, 12, 31, 23), Utc(2025, 9, 19, 12, 37)), (previousPeriod.From, previousPeriod.To)); + Assert.Equal(Day(2025, 12, 31), previousPeriod.NominalLastDay); + Assert.Equal(previousPeriod, previousYear with { Kind = ComparisonKind.PreviousPeriod }); + Assert.Equal((Utc(2022, 12, 31, 23), Utc(2023, 9, 19, 12, 37)), (named.From, named.To)); + Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, 3), named.Shift); + } + + [Fact] + public void The_last_12_months_compare_with_the_12_months_before() + { + var comparison = Applicable(Preset(PeriodPreset.Last12Months), PreviousPeriod); + + Assert.Equal(Day(2024, 10, 1), comparison.FirstDay); + Assert.Equal(Day(2025, 9, 30), comparison.NominalLastDay); + Assert.Equal((Utc(2024, 9, 30, 22), Utc(2025, 9, 19, 12, 37)), (comparison.From, comparison.To)); + Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 12), comparison.Shift); + } + + [Fact] + public void The_last_24_months_compare_with_the_24_months_before_or_shift_one_year_for_the_previous_year() + { + var current = Preset(PeriodPreset.Last24Months); + + var previousPeriod = Applicable(current, PreviousPeriod); + var previousYear = Applicable(current, PreviousYear); + + Assert.Equal(Day(2022, 10, 1), previousPeriod.FirstDay); + Assert.Equal(Utc(2024, 9, 19, 12, 37), previousPeriod.To); + Assert.Equal(Day(2023, 10, 1), previousYear.FirstDay); + Assert.Equal(Utc(2025, 9, 19, 12, 37), previousYear.To); + } + + [Fact] + public void A_complete_month_compares_with_the_complete_previous_month() + { + var august = Preset(PeriodPreset.LastMonth); + + var july = Applicable(august, PreviousPeriod); + var augustLastYear = Applicable(august, PreviousYear); + + Assert.Equal((Day(2026, 7, 1), Day(2026, 7, 31)), (july.FirstDay, july.LastDay)); + Assert.Equal((Utc(2026, 6, 30, 22), Utc(2026, 7, 31, 22)), (july.From, july.To)); + Assert.False(july.IsCutOff); + Assert.Equal((Utc(2025, 7, 31, 22), Utc(2025, 8, 31, 22)), (augustLastYear.From, augustLastYear.To)); + } + + [Fact] + public void A_whole_month_compares_with_the_whole_previous_month_however_long_each_is() + { + // February (28 days) against January (31 days), not against the 28 days before 1 February. + var february = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 3, 5, 12, 0)); + + var january = Applicable(february, PreviousPeriod); + + Assert.Equal((Day(2026, 1, 1), Day(2026, 1, 31)), (january.FirstDay, january.LastDay)); + Assert.Equal((Utc(2025, 12, 31, 23), Utc(2026, 1, 31, 23)), (january.From, january.To)); + } + + [Fact] + public void On_31_March_the_previous_period_is_all_of_February_because_February_has_no_31st() + { + var now = At(Berlin, 2026, 3, 31, 14, 37); + + var february = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); + + Assert.Equal((Day(2026, 2, 1), Day(2026, 2, 28)), (february.FirstDay, february.LastDay)); + Assert.Equal((Utc(2026, 1, 31, 23), Utc(2026, 2, 28, 23)), (february.From, february.To)); + Assert.False(february.IsCutOff); + } + + [Theory] + [InlineData(29)] + [InlineData(30)] + public void Late_March_days_February_lacks_also_compare_with_all_of_February(int day) + { + var february = Applicable(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, day, 14, 37)), PreviousPeriod); + + Assert.Equal(Utc(2026, 2, 28, 23), february.To); + } + + [Fact] + public void On_28_March_the_previous_period_stops_on_28_February_at_the_same_wall_clock_time() + { + // March 28 is still winter time and so is February: 14:37 CET both times. + var february = Applicable(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 28, 14, 37)), PreviousPeriod); + + Assert.Equal(Utc(2026, 2, 28, 13, 37), february.To); + Assert.Equal(Day(2026, 2, 28), february.LastDay); + Assert.True(february.IsCutOff); + } + + [Fact] + public void On_29_February_2028_the_previous_year_is_all_of_February_2027() + { + var now = At(Berlin, 2028, 2, 29, 10, 0); + + var month = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear); + var year = Applicable(Preset(PeriodPreset.YearToDate, now), PreviousYear); + + Assert.Equal((Day(2027, 2, 1), Day(2027, 2, 28)), (month.FirstDay, month.LastDay)); + Assert.Equal((Utc(2027, 1, 31, 23), Utc(2027, 2, 28, 23)), (month.From, month.To)); + Assert.False(month.IsCutOff); + + Assert.Equal((Day(2027, 1, 1), Day(2027, 2, 28)), (year.FirstDay, year.LastDay)); + Assert.Equal((Utc(2026, 12, 31, 23), Utc(2027, 2, 28, 23)), (year.From, year.To)); + Assert.True(year.IsCutOff); + } + + [Fact] + public void A_complete_leap_February_compares_with_the_whole_shorter_February_a_year_earlier() + { + var february2028 = Preset(PeriodPreset.LastMonth, At(Berlin, 2028, 3, 5, 12, 0)); + + var february2027 = Applicable(february2028, PreviousYear); + + Assert.Equal((Day(2027, 2, 1), Day(2027, 2, 28)), (february2027.FirstDay, february2027.NominalLastDay)); + Assert.Equal(Utc(2027, 2, 28, 23), february2027.To); + } + + [Fact] + public void A_cut_off_that_falls_in_the_spring_DST_gap_takes_the_first_instant_after_the_gap() + { + // 29 March 2027 02:30 exists (2027 switches on 28 March); 29 March 2026 02:30 does not. + var now = At(Berlin, 2027, 3, 29, 2, 30); + + var comparison = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear); + + Assert.Equal(Utc(2026, 3, 29, 1), comparison.To); + Assert.Equal("2026-03-29 03:00 +02:00", Wall(comparison.To, Berlin)); + } + + [Fact] + public void A_day_shifted_cut_off_into_the_spring_DST_gap_also_lands_on_the_end_of_the_gap() + { + var now = At(Berlin, 2026, 3, 30, 2, 30); + var today = Custom(Day(2026, 3, 30), Day(2026, 3, 30), now); + + var yesterday = Applicable(today, PreviousPeriod); + + Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Day, 1), yesterday.Shift); + Assert.Equal((Utc(2026, 3, 28, 23), Utc(2026, 3, 29, 1)), (yesterday.From, yesterday.To)); + } + + [Fact] + public void A_cut_off_in_the_repeated_autumn_hour_takes_the_occurrence_with_nows_winter_offset() + { + // Now is 02:30 winter time on 26 October; 25 October 02:30 happened twice. + var now = At(2026, 10, 26, 2, 30, offsetHours: 1); + var today = Custom(Day(2026, 10, 26), Day(2026, 10, 26), now); + + var yesterday = Applicable(today, PreviousPeriod); + + Assert.Equal(Utc(2026, 10, 25, 1, 30), yesterday.To); + Assert.Equal("2026-10-25 02:30 +01:00", Wall(yesterday.To, Berlin)); + } + + [Fact] + public void A_cut_off_in_the_repeated_autumn_hour_takes_the_occurrence_with_nows_summer_offset() + { + // 2027 leaves summer time on 31 October, so 25 October 2027 02:30 is summer time (+02:00). + var now = At(Berlin, 2027, 10, 25, 2, 30); + + var comparison = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear); + + Assert.Equal(Utc(2026, 10, 25, 0, 30), comparison.To); + Assert.Equal("2026-10-25 02:30 +02:00", Wall(comparison.To, Berlin)); + } + + [Fact] + public void An_ambiguous_wall_time_whose_offsets_do_not_include_nows_takes_the_first_occurrence() + { + var wall = new DateTime(2026, 10, 25, 2, 30, 0, DateTimeKind.Unspecified); + + var instant = LocalCalendar.InstantOf(wall, Berlin, TimeSpan.FromHours(5)); + + Assert.Equal(Utc(2026, 10, 25, 0, 30), instant); + } + + [Fact] + public void A_now_in_the_first_pass_of_the_repeated_hour_maps_into_an_ordinary_month_at_the_same_wall_clock_time() + { + var now = At(2026, 10, 25, 2, 30, offsetHours: 2); + + var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); + + Assert.Equal("2026-09-25 02:30 +02:00", Wall(september.To, Berlin)); + } + + [Fact] + public void A_now_in_the_second_pass_of_the_repeated_hour_maps_to_the_end_of_that_hour_in_an_ordinary_month() + { + // By 02:30 winter time the whole first pass (02:00 – 03:00 summer time) has elapsed. Mapping it to + // 02:30 again would compare 3.5 elapsed hours of the day with 2.5, and put the cut before the image + // of 02:45 summer time — a mapping that runs backwards, which matched coverage cannot invert. + var now = At(2026, 10, 25, 2, 30, offsetHours: 1); + + var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); + + Assert.Equal("2026-09-25 03:00 +02:00", Wall(september.To, Berlin)); + } + + [Fact] + public void A_month_shifted_cut_off_into_the_repeated_hour_takes_the_occurrence_with_nows_winter_offset() + { + // 25 November is winter time (+01:00); 25 October 02:30 happened twice. The first occurrence would be + // 00:30 UTC, so only now's offset explains 01:30 UTC. + var now = At(Berlin, 2026, 11, 25, 2, 30); + + var october = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); + + Assert.Equal(Utc(2026, 10, 25, 1, 30), october.To); + Assert.Equal("2026-10-25 02:30 +01:00", Wall(october.To, Berlin)); + } + + [Theory] + [InlineData(2, 0)] + [InlineData(1, 1)] + public void A_cut_off_in_a_repeated_hour_against_a_year_that_repeats_the_same_hour_keeps_its_pass(int offsetHours, int utcHour) + { + // 31 October is the autumn change both in 2027 and in 2021: first pass maps to first, second to second. + var now = At(2027, 10, 31, 2, 30, offsetHours); + + var year2021 = Applicable(Preset(PeriodPreset.YearToDate, now), new ComparisonRequest(ComparisonKind.Year, 2021)); + + Assert.Equal(Utc(2021, 10, 31, utcHour, 30), year2021.To); + } + + [Fact] + public void In_New_York_a_cut_off_in_the_spring_gap_takes_the_first_instant_after_it() + { + // 8 March 2026 skips 02:00 – 03:00; 8 April 02:30 exists. + var now = At(NewYork, 2026, 4, 8, 2, 30); + + var march = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod); + + Assert.Equal(Utc(2026, 3, 8, 7), march.To); + Assert.Equal("2026-03-08 03:00 -04:00", Wall(march.To, NewYork)); + } + + [Fact] + public void In_New_York_a_cut_off_in_the_repeated_hour_takes_nows_standard_offset() + { + // 1 November 2026 repeats 01:00 – 02:00; 1 December 01:30 is EST (-05:00). The first occurrence + // would be 05:30 UTC. + var now = At(NewYork, 2026, 12, 1, 1, 30); + + var november = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod); + + Assert.Equal(Utc(2026, 11, 1, 6, 30), november.To); + Assert.Equal(Utc(2026, 11, 1, 4), november.From); + } + + [Fact] + public void In_New_York_a_cut_off_in_the_repeated_hour_takes_nows_daylight_offset() + { + // 2027 falls back on 7 November, so 1 November 2027 01:30 is EDT (-04:00). + var now = At(NewYork, 2027, 11, 1, 1, 30); + + var november = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousYear); + + Assert.Equal(Utc(2026, 11, 1, 5, 30), november.To); + } + + [Fact] + public void Half_an_hour_into_New_Year_every_to_date_preset_compares_with_the_same_half_hour_before() + { + var now = At(Berlin, 2027, 1, 1, 0, 30); + + var december = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); + var january2026 = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear); + var year2026 = Applicable(Preset(PeriodPreset.YearToDate, now), PreviousPeriod); + var year2020 = Applicable(Preset(PeriodPreset.YearToDate, now), new ComparisonRequest(ComparisonKind.Year, 2020)); + var last12 = Applicable(Preset(PeriodPreset.Last12Months, now), PreviousPeriod); + + Assert.Equal((Utc(2026, 11, 30, 23), Utc(2026, 11, 30, 23, 30)), (december.From, december.To)); + Assert.Equal((Day(2026, 12, 1), Day(2026, 12, 1), Day(2026, 12, 31)), (december.FirstDay, december.LastDay, december.NominalLastDay)); + Assert.Equal((Utc(2025, 12, 31, 23), Utc(2025, 12, 31, 23, 30)), (january2026.From, january2026.To)); + Assert.Equal((Utc(2025, 12, 31, 23), Utc(2025, 12, 31, 23, 30)), (year2026.From, year2026.To)); + Assert.Equal(Day(2026, 12, 31), year2026.NominalLastDay); + Assert.Equal((Utc(2019, 12, 31, 23), Utc(2019, 12, 31, 23, 30)), (year2020.From, year2020.To)); + Assert.Equal((Day(2025, 2, 1), Day(2026, 1, 31)), (last12.FirstDay, last12.NominalLastDay)); + Assert.Equal((Utc(2025, 1, 31, 23), Utc(2025, 12, 31, 23, 30)), (last12.From, last12.To)); + Assert.All(new[] { december, january2026, year2026, year2020, last12 }, c => Assert.True(c.IsCutOff)); + } + + [Fact] + public void Half_an_hour_into_New_Year_last_month_is_December_and_compares_with_all_of_November() + { + var november = Applicable(Preset(PeriodPreset.LastMonth, At(Berlin, 2027, 1, 1, 0, 30)), PreviousPeriod); + + Assert.Equal((Utc(2026, 10, 31, 23), Utc(2026, 11, 30, 23)), (november.From, november.To)); + Assert.False(november.IsCutOff); + } + + [Fact] + public void At_the_midnight_that_starts_a_month_month_to_date_compares_with_an_equally_empty_start_of_the_previous_month() + { + var now = At(Berlin, 2026, 10, 1); + + var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod); + var last12 = Applicable(Preset(PeriodPreset.Last12Months, now), PreviousYear); + + // Nothing has elapsed on either side; the comparison applies, as it does for the last 12 months. + Assert.Equal((Utc(2026, 8, 31, 22), Utc(2026, 8, 31, 22)), (september.From, september.To)); + Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 1), Day(2026, 9, 30)), (september.FirstDay, september.LastDay, september.NominalLastDay)); + Assert.True(september.IsCutOff); + Assert.Equal(Utc(2025, 9, 30, 22), last12.To); + } + + [Fact] + public void A_day_range_starting_on_29_February_compares_from_1_March_because_that_day_has_no_counterpart() + { + var now = At(Berlin, 2028, 6, 1, 12, 0); + + var comparison = Applicable(Custom(Day(2028, 2, 29), Day(2028, 3, 5), now), PreviousYear); + + Assert.Equal((Day(2027, 3, 1), Day(2027, 3, 5)), (comparison.FirstDay, comparison.LastDay)); + Assert.Equal((Utc(2027, 2, 28, 23), Utc(2027, 3, 5, 23)), (comparison.From, comparison.To)); + + // 29 February alone has nothing to compare with. + Assert.Equal(ComparisonUnavailableReason.Empty, Reason(Custom(Day(2028, 2, 29), Day(2028, 2, 29), now), PreviousYear)); + } + + [Theory] + [InlineData("2026-03-31", "2026-03-01")] + [InlineData("2026-03-30", "2026-03-01")] + [InlineData("2026-03-29", "2026-03-01")] + [InlineData("2026-03-28", "2026-02-28")] + [InlineData("2026-03-01", "2026-02-01")] + [InlineData("2026-04-01", "2026-03-01")] + public void Shifting_a_day_back_a_month_collapses_the_days_the_target_lacks_onto_its_end(string date, string expected) + { + Assert.Equal(Iso(expected), ComparisonResolver.ShiftDate(Iso(date), new ComparisonShift(ComparisonShiftUnit.Month, 1))); + } + + [Fact] + public void Shifting_29_February_back_a_year_lands_on_1_March_and_a_day_shift_is_exact() + { + Assert.Equal(Day(2027, 3, 1), ComparisonResolver.ShiftDate(Day(2028, 2, 29), new ComparisonShift(ComparisonShiftUnit.Year, 1))); + Assert.Equal(Day(2028, 2, 28), ComparisonResolver.ShiftDate(Day(2029, 2, 28), new ComparisonShift(ComparisonShiftUnit.Year, 1))); + Assert.Equal(Day(2027, 3, 31), ComparisonResolver.ShiftDate(Day(2026, 3, 31), new ComparisonShift(ComparisonShiftUnit.Year, -1))); + Assert.Equal(Day(2026, 2, 21), ComparisonResolver.ShiftDate(Day(2026, 3, 3), new ComparisonShift(ComparisonShiftUnit.Day, 10))); + } + + [Fact] + public void A_covered_range_that_starts_on_a_day_the_target_month_lacks_starts_where_that_month_ends() + { + var shift = new ComparisonShift(ComparisonShiftUnit.Month, 1); + var offset = TimeSpan.FromHours(2); + + var start = ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 30, 10, 0), shift, Berlin, offset); + var sameDayEnd = ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 31, 14, 37), shift, Berlin, offset); + var aprilEnd = ComparisonResolver.MapInstant(At(Berlin, 2026, 4, 5, 12, 0), shift, Berlin, offset); + + // Coverage of 30 – 31 March has no counterpart in February: its image is empty… + Assert.Equal(Utc(2026, 2, 28, 23), start); + Assert.Equal(start, sameDayEnd); + + // …and coverage running on into April matches from 1 March, not from a clamped 28 February. + Assert.Equal(Utc(2026, 3, 5, 11), aprilEnd); + } + + [Fact] + public void The_start_of_a_local_day_maps_to_the_start_of_the_target_day() + { + var month = new ComparisonShift(ComparisonShiftUnit.Month, 1); + var year = new ComparisonShift(ComparisonShiftUnit.Year, 1); + + Assert.Equal(Utc(2026, 2, 28, 23), ComparisonResolver.MapInstant(At(Berlin, 2026, 4, 1), month, Berlin, TimeSpan.FromHours(2))); + Assert.Equal(Utc(2025, 3, 29, 23), ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 30), year, Berlin, TimeSpan.FromHours(2))); + Assert.Equal(Utc(2026, 10, 1, 4), ComparisonResolver.MapInstant(At(NewYork, 2026, 11, 1), month, NewYork, TimeSpan.FromHours(-5))); + } + + [Fact] + public void The_comparison_bounds_are_the_images_of_the_current_bounds_under_the_public_mapping() + { + var cases = new (ResolvedPeriod Current, ComparisonRequest Request)[] + { + (Preset(PeriodPreset.MonthToDate), PreviousPeriod), + (Preset(PeriodPreset.MonthToDate), PreviousYear), + (Preset(PeriodPreset.YearToDate), new ComparisonRequest(ComparisonKind.Year, 2023)), + (Preset(PeriodPreset.Last12Months), PreviousPeriod), + (Preset(PeriodPreset.LastMonth), PreviousPeriod), + (Custom(Day(2026, 9, 10), Day(2026, 9, 19)), PreviousPeriod), + (Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 31, 14, 37)), PreviousPeriod), + (Preset(PeriodPreset.MonthToDate, At(2026, 10, 25, 2, 30, offsetHours: 1)), PreviousPeriod), + (Preset(PeriodPreset.YearToDate, At(Berlin, 2028, 2, 29, 10, 0)), PreviousYear), + (Preset(PeriodPreset.MonthToDate, At(NewYork, 2026, 12, 1, 1, 30), NewYork), PreviousPeriod), + }; + + foreach (var (current, request) in cases) + { + var comparison = Applicable(current, request); + + Assert.Equal(comparison.From, comparison.MapInstant(current.From, current.Zone)); + Assert.Equal(comparison.To, comparison.MapInstant(current.To, current.Zone)); + } + } + + public static TheoryData SweepZones => ["Europe/Berlin", "America/New_York", "America/Santiago", "Australia/Lord_Howe"]; + + [Theory] + [MemberData(nameof(SweepZones))] + public void The_instant_mapping_never_runs_backwards_across_DST_changes_and_short_months(string zoneId) + { + // Matched coverage (D-07) inverts the mapping by bisection, which needs it non-decreasing. A year of + // quarter hours crosses both DST changes, both passes of the repeated hour, and every short month. + var zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId); + ComparisonShift[] shifts = + [ + new(ComparisonShiftUnit.Day, 1), + new(ComparisonShiftUnit.Day, 10), + new(ComparisonShiftUnit.Month, 1), + new(ComparisonShiftUnit.Month, 12), + new(ComparisonShiftUnit.Year, 1), + new(ComparisonShiftUnit.Year, -1), + ]; + TimeSpan[] offsets = [zone.GetUtcOffset(Utc(2026, 1, 15)), zone.GetUtcOffset(Utc(2026, 7, 15))]; + + foreach (var shift in shifts) + { + foreach (var offset in offsets) + { + var previousInstant = Utc(2026, 1, 1); + var previous = ComparisonResolver.MapInstant(previousInstant, shift, zone, offset); + for (var instant = previousInstant.AddMinutes(15); instant < Utc(2027, 1, 1); instant = instant.AddMinutes(15)) + { + var mapped = ComparisonResolver.MapInstant(instant, shift, zone, offset); + if (mapped < previous) + { + Assert.Fail($"{shift} with offset {offset}: {Wall(instant, zone)} maps to {Wall(mapped, zone)}, before {Wall(previousInstant, zone)} → {Wall(previous, zone)}."); + } + + previousInstant = instant; + previous = mapped; + } + } + } + } + + [Fact] + public void A_cut_comparison_reads_as_a_period_to_date_as_of_its_mapped_cut_off() + { + var current = Preset(PeriodPreset.MonthToDate); + var comparison = Applicable(current, PreviousPeriod); + + var period = comparison.ToResolvedPeriod(current); + + Assert.Equal(PeriodPreset.Custom, period.Preset); + Assert.Equal((Day(2026, 8, 1), Day(2026, 8, 31)), (period.FirstDay, period.LastDay)); + Assert.Equal((comparison.From, comparison.To, comparison.To), (period.From, period.To, period.Now)); + Assert.True(period.IsToDate); + Assert.True(period.ExtendsPastNow); + Assert.Same(Berlin, period.Zone); + Assert.Equal(Day(2026, 8, 19), period.EffectiveLastDay()); + Assert.Equal(Day(2026, 8, 31), period.NominalLastDay()); + + var days = BucketPlanner.Plan(period, BucketSize.Day); + var month = Assert.Single(BucketPlanner.Plan(period, BucketSize.Month).Buckets); + Assert.Equal(19, days.Buckets.Count); + Assert.Equal(comparison.To, days.Buckets[^1].To); + Assert.Equal((Day(2026, 8, 20), Day(2026, 9, 1)), (month.EndDay, month.NominalEndDay)); + } + + [Fact] + public void A_complete_comparison_reads_as_a_complete_period() + { + var current = Preset(PeriodPreset.LastMonth); + + var july = Applicable(current, PreviousPeriod).ToResolvedPeriod(current); + + Assert.False(july.IsToDate); + Assert.Equal((Day(2026, 7, 1), Day(2026, 7, 31)), (july.FirstDay, july.LastDay)); + Assert.Equal((Utc(2026, 6, 30, 22), Utc(2026, 7, 31, 22)), (july.From, july.To)); + Assert.Equal(current.Now, july.Now); + Assert.Equal(31, BucketPlanner.Plan(july, BucketSize.Day).Buckets.Count); + } + + [Fact] + public void A_comparison_cut_at_a_midnight_by_a_missing_day_reads_as_complete_without_an_empty_extra_day() + { + var current = Preset(PeriodPreset.YearToDate, At(Berlin, 2028, 2, 29, 10, 0)); + var comparison = Applicable(current, PreviousYear); + + var period = comparison.ToResolvedPeriod(current); + + Assert.False(period.IsToDate); + Assert.Equal((Day(2027, 2, 28), comparison.To), (period.LastDay, period.To)); + Assert.Equal( + [Day(2027, 1, 1), Day(2027, 2, 1)], + BucketPlanner.Plan(period, BucketSize.Month).Buckets.Select(b => b.FirstDay)); + } + + [Fact] + public void At_midnight_the_comparison_keeps_the_same_empty_last_day_as_the_current_period() + { + var now = At(Berlin, 2026, 10, 1); + var last12 = Preset(PeriodPreset.Last12Months, now); + var monthToDate = Preset(PeriodPreset.MonthToDate, now); + + var yearBefore = Applicable(last12, PreviousYear).ToResolvedPeriod(last12); + var monthBefore = Applicable(monthToDate, PreviousPeriod).ToResolvedPeriod(monthToDate); + + Assert.True(yearBefore.IsToDate); + Assert.Equal(12, BucketPlanner.Plan(yearBefore, BucketSize.Month).Buckets.Count); + var empty = Assert.Single(BucketPlanner.Plan(monthBefore, BucketSize.Day).Buckets); + Assert.Equal((Day(2026, 9, 1), empty.From), (empty.FirstDay, empty.To)); + } + + [Fact] + public void A_named_later_year_still_running_reads_as_to_date_at_the_real_now() + { + var current = Preset(PeriodPreset.PreviousYear); + + var year2026 = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2026)).ToResolvedPeriod(current); + + Assert.True(year2026.IsToDate); + Assert.Equal((September19, September19), (year2026.To, year2026.Now)); + Assert.Equal((Day(2026, 12, 31), Day(2026, 9, 19)), (year2026.LastDay, year2026.EffectiveLastDay())); + } + + [Fact] + public void Month_to_date_day_buckets_pair_with_the_same_days_of_the_previous_month() + { + var current = Preset(PeriodPreset.MonthToDate); + var comparison = Applicable(current, PreviousPeriod); + var plan = BucketPlanner.Plan(current, BucketSize.Day); + + var pairs = ComparisonResolver.PairBuckets(current, comparison, plan.Buckets); + + Assert.Equal(plan.Buckets, pairs.Select(p => p.Current)); + Assert.Equal(Enumerable.Range(1, 19).Select(d => Day(2026, 8, d)), pairs.Select(p => p.Comparison.FirstDay)); + Assert.All(pairs, p => Assert.Equal(BucketSize.Day, p.Comparison.Size)); + Assert.Equal(Utc(2026, 8, 19, 12, 37), pairs[^1].Comparison.To); + AssertTiles(comparison, pairs); + } + + [Fact] + public void The_cut_short_current_month_pairs_with_a_cut_short_month_naming_its_whole_unit() + { + var current = Preset(PeriodPreset.Last12Months); + var comparison = Applicable(current, PreviousYear); + + var pairs = ComparisonResolver.PairBuckets(current, comparison, BucketPlanner.Plan(current, BucketSize.Month).Buckets); + + Assert.Equal(12, pairs.Count); + Assert.All(pairs, p => Assert.Equal(p.Current.FirstDay.AddYears(-1), p.Comparison.FirstDay)); + var september = pairs[^1].Comparison; + Assert.Equal((Day(2025, 9, 1), Day(2025, 9, 20), Day(2025, 10, 1)), (september.FirstDay, september.EndDay, september.NominalEndDay)); + Assert.Equal(Utc(2025, 9, 19, 12, 37), september.To); + AssertTiles(comparison, pairs); + } + + [Fact] + public void Day_buckets_of_February_pair_its_last_day_with_the_rest_of_January_so_the_pairs_add_up() + { + var february = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 3, 5, 12, 0)); + var january = Applicable(february, PreviousPeriod); + + var pairs = ComparisonResolver.PairBuckets(february, january, BucketPlanner.Plan(february, BucketSize.Day).Buckets); + + Assert.Equal(28, pairs.Count); + Assert.Equal((Day(2026, 1, 27), Day(2026, 1, 28)), (pairs[^2].Comparison.FirstDay, pairs[^2].Comparison.EndDay)); + Assert.Equal((Day(2026, 1, 28), Day(2026, 2, 1)), (pairs[^1].Comparison.FirstDay, pairs[^1].Comparison.EndDay)); + AssertTiles(january, pairs); + } + + [Fact] + public void Days_of_March_that_February_lacks_pair_with_empty_buckets() + { + var march = Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 31, 14, 37)); + var february = Applicable(march, PreviousPeriod); + + var pairs = ComparisonResolver.PairBuckets(march, february, BucketPlanner.Plan(march, BucketSize.Day).Buckets); + + Assert.Equal(31, pairs.Count); + Assert.Equal((Day(2026, 2, 28), Day(2026, 3, 1)), (pairs[27].Comparison.FirstDay, pairs[27].Comparison.EndDay)); + Assert.All(pairs.Skip(28), p => Assert.Equal((p.Comparison.From, p.Comparison.FirstDay), (p.Comparison.To, p.Comparison.EndDay))); + AssertTiles(february, pairs); + } + + [Fact] + public void Against_a_named_later_year_still_running_the_buckets_after_now_pair_with_empty_ones() + { + var current = Preset(PeriodPreset.PreviousYear); + var comparison = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2026)); + + var pairs = ComparisonResolver.PairBuckets(current, comparison, BucketPlanner.Plan(current, BucketSize.Month).Buckets); + + Assert.Equal(12, pairs.Count); + var september = pairs[8].Comparison; + Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20), Day(2026, 10, 1)), (september.FirstDay, september.EndDay, september.NominalEndDay)); + Assert.Equal(September19, september.To); + Assert.All(pairs.Skip(9), p => + { + Assert.Equal((September19, September19), (p.Comparison.From, p.Comparison.To)); + Assert.Equal(p.Comparison.FirstDay, p.Comparison.EndDay); + Assert.True(p.Comparison.IsCutShort); + }); + AssertTiles(comparison, pairs); + } + + [Fact] + public void All_history_without_data_has_no_current_period_to_compare() + { + var none = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin); + + Assert.Equal(ComparisonUnavailableReason.NoCurrentPeriod, Reason(none, PreviousYear)); + } + + [Fact] + public void A_comparison_outside_the_supported_years_is_reported_as_out_of_range_never_thrown() + { + var year1900 = Custom(Day(1900, 1, 1), Day(1900, 12, 31)); + var yearToDate = Preset(PeriodPreset.YearToDate); + + Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(year1900, PreviousYear)); + Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(year1900, PreviousPeriod)); + foreach (var year in new[] { 1, 1899, 2300, 9999, int.MinValue, int.MaxValue }) + { + Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, year))); + } + } + + private static void AssertTiles(ComparisonPeriod comparison, IReadOnlyList pairs) + { + Assert.Equal(comparison.From, pairs[0].Comparison.From); + Assert.Equal(comparison.To, pairs[^1].Comparison.To); + for (var i = 1; i < pairs.Count; i++) + { + Assert.Equal(pairs[i - 1].Comparison.To, pairs[i].Comparison.From); + } + } + + [Fact] + public void Behind_UTC_the_cut_off_keeps_the_wall_clock_time_across_the_DST_change() + { + // 19 November noon in New York is EST (-5); 19 October noon was EDT (-4). + var now = At(NewYork, 2026, 11, 19, 12, 0); + + var october = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod); + + Assert.Equal((Utc(2026, 10, 1, 4), Utc(2026, 10, 19, 16)), (october.From, october.To)); + } + + [Fact] + public void A_custom_month_reaching_past_now_compares_like_month_to_date() + { + var custom = Applicable(Custom(Day(2026, 9, 1), Day(2026, 9, 30)), PreviousPeriod); + var monthToDate = Applicable(Preset(PeriodPreset.MonthToDate), PreviousPeriod); + + Assert.Equal(monthToDate, custom); + } + + [Fact] + public void A_custom_range_of_days_shifts_back_by_its_own_length() + { + var comparison = Applicable(Custom(Day(2026, 9, 10), Day(2026, 9, 19)), PreviousPeriod); + + Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Day, 10), comparison.Shift); + Assert.Equal((Day(2026, 8, 31), Day(2026, 9, 9)), (comparison.FirstDay, comparison.LastDay)); + Assert.Equal(Utc(2026, 9, 9, 12, 37), comparison.To); + } + + [Fact] + public void A_custom_quarter_compares_with_the_quarter_before() + { + var comparison = Applicable(Custom(Day(2026, 1, 1), Day(2026, 3, 31)), PreviousPeriod); + + Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 3), comparison.Shift); + Assert.Equal((Day(2025, 10, 1), Day(2025, 12, 31)), (comparison.FirstDay, comparison.LastDay)); + Assert.Equal(Utc(2025, 12, 31, 23), comparison.To); + } + + [Fact] + public void A_complete_year_compares_with_the_year_before_or_any_named_year() + { + var year2025 = Preset(PeriodPreset.PreviousYear); + + var year2024 = Applicable(year2025, PreviousPeriod); + var year2020 = Applicable(year2025, new ComparisonRequest(ComparisonKind.Year, 2020)); + + Assert.Equal((Utc(2023, 12, 31, 23), Utc(2024, 12, 31, 23)), (year2024.From, year2024.To)); + Assert.Equal((Day(2020, 1, 1), Day(2020, 12, 31)), (year2020.FirstDay, year2020.LastDay)); + } + + [Fact] + public void A_named_later_year_still_in_progress_is_capped_at_now() + { + var comparison = Applicable(Preset(PeriodPreset.PreviousYear), new ComparisonRequest(ComparisonKind.Year, 2026)); + + Assert.Equal(Utc(2025, 12, 31, 23), comparison.From); + Assert.Equal(September19, comparison.To); + Assert.True(comparison.CappedAtNow); + Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, -1), comparison.Shift); + } + + [Fact] + public void Comparisons_that_cannot_apply_say_why() + { + var yearToDate = Preset(PeriodPreset.YearToDate); + + Assert.Equal(ComparisonUnavailableReason.NotRequested, Reason(yearToDate, ComparisonRequest.None)); + Assert.Equal( + ComparisonUnavailableReason.AllHistory, + Reason(PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1997, 1, 1)), PreviousYear)); + Assert.Equal( + ComparisonUnavailableReason.NotYearAligned, + Reason(Preset(PeriodPreset.MonthToDate), new ComparisonRequest(ComparisonKind.Year, 2024))); + Assert.Equal( + ComparisonUnavailableReason.NotYearAligned, + Reason(Preset(PeriodPreset.Last12Months), new ComparisonRequest(ComparisonKind.Year, 2024))); + Assert.Equal( + ComparisonUnavailableReason.NotYearAligned, + Reason(Custom(Day(2023, 1, 1), Day(2024, 12, 31)), new ComparisonRequest(ComparisonKind.Year, 2020))); + Assert.Equal(ComparisonUnavailableReason.SameYear, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, 2026))); + Assert.Equal( + ComparisonUnavailableReason.ComparisonNotYetOccurred, + Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, 2027))); + Assert.Equal(ComparisonUnavailableReason.MissingYear, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year))); + Assert.Equal( + ComparisonUnavailableReason.CurrentNotYetOccurred, + Reason(Custom(Day(2027, 1, 1), Day(2027, 1, 31)), PreviousPeriod)); + } + + [Fact] + public void In_December_the_last_12_months_are_a_calendar_year_and_accept_a_named_year() + { + var current = Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 12, 10, 12, 0)); + + var comparison = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2024)); + + Assert.Equal(Day(2024, 1, 1), comparison.FirstDay); + Assert.Equal(Utc(2024, 12, 10, 11), comparison.To); + } + + [Fact] + public void Shifting_off_the_representable_calendar_is_reported_rather_than_thrown() + { + var utc = TimeZoneInfo.Utc; + var yearOne = new ResolvedPeriod( + PeriodPreset.Custom, + new DateOnly(1, 1, 1), + new DateOnly(1, 12, 31), + new DateTimeOffset(1, 1, 1, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2, 1, 1, 0, 0, 0, TimeSpan.Zero), + September19, + IsToDate: false, + ExtendsPastNow: false, + utc); + + Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(yearOne, PreviousYear)); + } +} diff --git a/tests/Core.Tests/Analysis/Costing/CostAmountTests.cs b/tests/Core.Tests/Analysis/Costing/CostAmountTests.cs new file mode 100644 index 0000000..e38cafa --- /dev/null +++ b/tests/Core.Tests/Analysis/Costing/CostAmountTests.cs @@ -0,0 +1,167 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData; + +namespace MeterVault.Core.Tests.Analysis.Costing; + +/// +/// Cost figures fold exactly: the sum of the parts is the same figure — value, status and missing prices — however it +/// is grouped. And the calculator refuses input it cannot price honestly. +/// +public sealed class CostAmountTests +{ + [Fact] + public void The_empty_figure_is_priced_with_no_value() + { + Assert.Equal(CostStatus.Priced, CostAmount.Empty.Status); + Assert.Null(CostAmount.Empty.Cost); + Assert.Equal(BucketStatus.Available, CostAmount.Empty.Availability); + Assert.Empty(CostAmount.Empty.MissingPrices); + + var sum = CostAmount.Sum([CostAmount.Empty, CostAmount.Empty]); + Assert.Equal((CostStatus.Priced, (double?)null, false), (sum.Status, sum.Cost, sum.IncludesNotPriced)); + } + + [Fact] + public void Summing_is_associative_over_lines_and_buckets() + { + // A priced grid line, a water line with a gap, an unpriced tank, a standing charge and a manual cost: the bill + // total must be the same whether the buckets or the lines are added first. + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + TypePrice(2, Water, 5, "EUR/m3", D(2025, 3, 1)), + BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 6, 30)); + + var result = Price( + buckets, + book, + [Line(Grid, Electricity, Each(buckets, 100)), Line(WaterMeter, Water, Each(buckets, 10), "m³"), Line(OilTank, Oil, Each(buckets, 50), "L")], + standing: [StandingChargeScope.ForEnergyType(Electricity, new ServicePeriod(D(2020, 1, 1)))], + manual: [Manual(1, D(2025, 4, 2), 99)]); + + var byLine = CostAmount.Sum(result.Lines.Select(l => l.Total).Concat(result.StandingCharges.Select(r => r.Total)).Append(result.ManualCosts.Total)); + var byBucket = CostAmount.Sum(result.Totals); + + foreach (var figure in new[] { byLine, byBucket }) + { + Assert.Equal(result.Total.Cost!.Value, figure.Cost!.Value, 9); + Assert.Equal(result.Total.Status, figure.Status); + Assert.Equal(result.Total.IncludesNotPriced, figure.IncludesNotPriced); + Assert.Equal(result.Total.MissingPrices, figure.MissingPrices); + } + + Assert.Equal(CostStatus.Partial, result.Total.Status); + Assert.Equal((6 * 30) + (4 * 50) + (6 * 12) + 99, result.Total.Cost!.Value, 9); + } + + [Fact] + public void Missing_prices_merge_into_one_entry_with_their_first_and_last_month() + { + var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 6, 1))); + var buckets = Buckets(BucketSize.Day, D(2025, 3, 30), D(2025, 5, 2)); + + var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 1), "m³")]); + + var missing = Assert.Single(result.MissingPrices); + Assert.Equal((D(2025, 3, 1), D(2025, 5, 1)), (missing.FirstMonth, missing.LastMonth)); + + // Each day bucket names its own month. + Assert.Equal(D(2025, 4, 1), Assert.Single(result.Totals.At(buckets, D(2025, 4, 17)).MissingPrices).FirstMonth); + } + + [Fact] + public void A_unit_mismatch_outranks_a_gap_when_nothing_is_priced() + { + var book = Book(Tariff(1, TariffScope.EnergyType, Water, TariffComponent.UnitPrice, 0.3, "EUR/kWh", D(2025, 2, 1))); + var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 2, 28)); + + var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 10), "m³")]); + + Assert.Equal(CostStatus.UnitMismatch, result.Total.Status); + Assert.Equal([CostStatus.PriceGap, CostStatus.UnitMismatch], result.MissingPrices.Select(m => m.Reason)); + Assert.Null(result.Total.Cost); + } + + [Fact] + public void Availability_reports_the_most_telling_reason_when_nothing_is_known() + { + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 3, 31)); + var parts = CostCalculator.Parts(buckets); + + var result = Price( + buckets, + book, + [Line(Grid, Electricity, [ + CostQuantity.Unknown(parts[0]), + CostQuantity.Unknown(parts[1], BucketStatus.Unresolved), + CostQuantity.Unknown(parts[2], BucketStatus.Pending)])]); + + Assert.Equal(BucketStatus.Pending, result.Total.Availability); + Assert.Null(result.Total.Cost); + } + + // ---- refusing malformed input --------------------------------------------------------------------- + + [Fact] + public void A_quantity_that_is_not_a_part_of_the_request_is_refused() + { + var buckets = Buckets(BucketSize.Week, D(2025, 1, 27), D(2025, 2, 2)); + + // The whole week in one quantity would be priced at one month's price: the caller must split it. + var wholeWeek = new CostQuantity(D(2025, 1, 27), D(2025, 2, 3), 70, BucketStatus.Available); + + Assert.Throws(() => Price(buckets, Book(), [Line(Grid, Electricity, [wholeWeek])])); + } + + [Fact] + public void A_quantity_given_twice_or_not_finite_is_refused() + { + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31)); + var part = CostCalculator.Parts(buckets).Single(); + + Assert.Throws(() => Price(buckets, Book(), [Line(Grid, Electricity, [CostQuantity.Known(part, 1), CostQuantity.Known(part, 2)])])); + Assert.Throws(() => Price(buckets, Book(), [Line(Grid, Electricity, [CostQuantity.Known(part, double.NaN)])])); + } + + [Fact] + public void Overlapping_buckets_are_refused() + { + var buckets = new[] { Bucket(D(2025, 1, 1), D(2025, 2, 1)), Bucket(D(2025, 1, 15), D(2025, 1, 16), BucketSize.Day) }; + + Assert.Throws(() => CostCalculator.Parts(buckets)); + } + + [Fact] + public void A_standing_charge_row_names_its_scope_and_a_meter_with_a_line_is_not_a_row() + { + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31)); + + // A-18: a meter's own row is for a meter without a line; with one, its charge accrues on the line. + Assert.Throws(() => Price(buckets, Book(), [], standing: [new StandingChargeScope(TariffScope.Meter, null, null)])); + Assert.Throws(() => Price( + buckets, Book(), [Line(Grid, Electricity, Each(buckets, 1))], standing: [StandingChargeScope.ForMeter(Grid, null)])); + Assert.Throws(() => Price(buckets, Book(), [], standing: [new StandingChargeScope(TariffScope.EnergyType, null, null)])); + Assert.Throws(() => Price( + buckets, Book(), [], standing: [StandingChargeScope.Global(null), StandingChargeScope.Global(null)])); + } + + [Fact] + public void A_service_period_cannot_end_before_it_starts() + { + Assert.Throws(() => new ServicePeriod(D(2025, 2, 1), D(2025, 1, 31))); + } + + [Fact] + public void An_empty_request_prices_nothing() + { + var result = Price([], Book(), []); + + Assert.Empty(result.Totals); + Assert.Null(result.Total.Cost); + Assert.Equal("EUR", result.Currency); + } +} diff --git a/tests/Core.Tests/Analysis/Costing/CostCalculatorBillTests.cs b/tests/Core.Tests/Analysis/Costing/CostCalculatorBillTests.cs new file mode 100644 index 0000000..279911d --- /dev/null +++ b/tests/Core.Tests/Analysis/Costing/CostCalculatorBillTests.cs @@ -0,0 +1,180 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Costing; +using MeterVault.Core.Domain; +using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData; +using Seed = MeterVault.Core.Tests.Analysis.TotalsSeed; + +namespace MeterVault.Core.Tests.Analysis.Costing; + +/// +/// The calculator prices what bills (D-34/D-35): on the reference data that is Netz × price, +/// the spreadsheet's Kosten; a separately billed heat pump at its own price; and virtual meters by their named +/// cost rule (D-39). +/// +public sealed class CostCalculatorBillTests +{ + /// The seeded electricity price history (ReferenceDataImporter). + private static readonly Tariff[] StromPrices = + [ + TypePrice(1, Seed.Electricity, 0.16, "EUR/kWh", D(2022, 9, 1)), + TypePrice(2, Seed.Electricity, 0.44, "EUR/kWh", D(2023, 1, 1)), + TypePrice(3, Seed.Electricity, 0.37, "EUR/kWh", D(2023, 5, 1)), + TypePrice(4, Seed.Electricity, 0.27, "EUR/kWh", D(2023, 11, 1)), + TypePrice(5, Seed.Electricity, 0.36, "EUR/kWh", D(2025, 1, 1)), + TypePrice(6, Seed.Electricity, 0.27, "EUR/kWh", D(2026, 1, 1)), + ]; + + [Fact] + public void The_seeded_Strom_bill_prices_the_grid_import_as_the_sheet_does() + { + // Kosten = Verbrauchskosten − Ersparnis = Netz × €/kWh: Oct 2022 416 × 0.16, Jan 2023 1170 × 0.44, May 2026 827 × 0.27. + var classification = TotalsPolicy.Classify(Seed.Meters(), Seed.Links()); + var bill = classification.ForType(Seed.Electricity).Billing; + var netz = new Dictionary { [D(2022, 10, 1)] = 416, [D(2023, 1, 1)] = 1170, [D(2026, 5, 1)] = 827 }; + var book = Book(StromPrices); + + foreach (var (month, amount) in netz) + { + var buckets = Buckets(BucketSize.Month, month, month.AddMonths(1).AddDays(-1)); + var lines = bill.Lines + .Select(l => new CostLine(l.MeterId, Seed.Electricity, l.Kind, "kWh", l.MeterId == Seed.Netz ? Each(buckets, amount) : Each(buckets, 9999))) + .ToList(); + + var result = CostCalculator.Calculate(new CostRequest(buckets, D(2026, 9, 19), book, lines)); + + Assert.Equal([Seed.Netz], result.Lines.Select(l => l.MeterId)); + Assert.Equal(amount * SheetPrice(month), result.Total.Cost!.Value, 9); + } + + static double SheetPrice(DateOnly month) => + TariffResolver.ResolveValue(StromPrices, TariffComponent.UnitPrice, Seed.Netz, Seed.Electricity, TariffBook.PriceDate(month)); + } + + [Fact] + public void A_separately_billed_heat_pump_is_priced_at_its_own_price_out_of_the_grid_import() + { + // SeparatelyBilledSubmeterTests: main 300 kWh at 0.30 and heat pump 100 kWh at 0.22 bill 200 × 0.30 + 100 × 0.22. + const int heatPump = 10; + var meters = Seed.Meters(); + meters.Add(Seed.Physical(heatPump, "Wärmepumpe", Seed.Electricity, MeterMode.CumulativeCounter, "kWh")); + var book = Book(TypePrice(1, Seed.Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), MeterPrice(2, heatPump, 0.22, "EUR/kWh", D(2025, 1, 1))); + var classification = TotalsPolicy.Classify(meters, [.. Seed.Links(), new(Seed.Haus, heatPump)], book.HasMeterScopedUnitPrice); + var bill = classification.ForType(Seed.Electricity).Billing; + var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30)); + var gross = new Dictionary { [Seed.Netz] = 300, [heatPump] = 100 }; + + // The engine's part: net quantities, the deductions taken out of the line they belong to. + var lines = bill.Lines + .Select(l => new CostLine( + l.MeterId, + Seed.Electricity, + l.Kind, + "kWh", + Each(buckets, gross[l.MeterId] - l.Deductions.Sum(d => gross[d.MeterId] * d.UnitFactor)))) + .ToList(); + + var result = CostCalculator.Calculate(new CostRequest(buckets, D(2026, 1, 1), book, lines)); + + Assert.Equal([(Seed.Netz, BillLineKind.UnitPrice), (heatPump, BillLineKind.OwnPrice)], result.Lines.Select(l => (l.MeterId, l.Kind))); + Assert.Equal(60, result.Lines[0].Total.Cost!.Value, 9); + Assert.Equal(22, result.Lines[1].Total.Cost!.Value, 9); + Assert.Equal(82, result.Total.Cost!.Value, 9); + } + + [Fact] + public void Before_its_own_price_starts_a_subsection_is_billed_inside_its_parent() + { + // The heat pump's own tariff starts in March. In January and February it is not billed separately: the engine + // leaves its energy in the grid import (HasOwnUnitPrice), and its own line has nothing — no gap, no zero. + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), MeterPrice(2, Heater, 0.22, "EUR/kWh", D(2025, 3, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31)); + var grid = Monthly(buckets, m => book.HasOwnUnitPrice(Heater, m) ? 300 - 100 : 300); + + var result = Price(buckets, book, [Line(Grid, Electricity, grid), Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice)]); + + var heater = result.Lines[1]; + Assert.Equal([D(2025, 1, 1), D(2025, 2, 1)], heater.MonthsWithoutOwnPrice); + Assert.Null(heater.Buckets[0].Cost); + Assert.Equal(CostStatus.Priced, heater.Buckets[0].Status); + Assert.Equal(22, heater.Buckets[2].Cost!.Value, 9); + Assert.Empty(result.MissingPrices); + Assert.Equal([90d, 90d, 60d + 22d], result.Totals.Select(t => Math.Round(t.Cost!.Value, 9))); + } + + [Fact] + public void An_own_price_line_never_takes_the_type_price() + { + // D-35's OwnPrice means the meter's own price: a line for a meter without one is not priced, not priced at the type's. + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31)); + + var result = Price(buckets, book, [Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice)]); + + Assert.Equal(CostStatus.NotPriced, result.Total.Status); + Assert.Equal((TariffScope.Meter, (int?)Heater), (result.MissingPrices[0].Scope, result.MissingPrices[0].ScopeId)); + } + + [Fact] + public void Source_costs_and_own_quantity_differ_when_the_sources_have_different_prices() + { + // D-39: a virtual sum of the house (type price 0.30) and a heat pump (own price 0.22), 100 kWh each. Summing the + // sources' costs gives 52; pricing the sum as its own quantity takes the type price: 60. The rule is named. + const int sum = 40; + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + MeterPrice(2, Heater, 0.22, "EUR/kWh", D(2025, 1, 1)), + BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30)); + + // sourceCosts: each source at its own precedence, without the type's standing charge. + var sourceCosts = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100)), Line(Heater, Electricity, Each(buckets, 100))]); + + // ownQuantity: the virtual meter's evaluated quantity as one line. + var ownQuantity = Price(buckets, book, [Line(sum, Electricity, Each(buckets, 200))]); + + Assert.Equal(52, sourceCosts.Total.Cost!.Value, 9); + Assert.Empty(sourceCosts.StandingCharges); + Assert.Equal(60, ownQuantity.Total.Cost!.Value, 9); + } + + [Fact] + public void Category_slices_add_up_to_the_bill() + { + // D-42: the composition of disjoint slices reconciles, because a figure is the exact sum of its lines. + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + TypePrice(2, Water, 5, "EUR/m3", D(2025, 2, 1)), + BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31)); + var always = new ServicePeriod(D(2020, 1, 1)); + + var bill = Price( + buckets, + book, + [Line(Grid, Electricity, Each(buckets, 100)), Line(WaterMeter, Water, Each(buckets, 10), "m³"), Line(OilTank, Oil, Each(buckets, 300), "L")], + standing: [StandingChargeScope.ForEnergyType(Electricity, always)], + manual: [Manual(1, D(2025, 2, 1), 100, categoryId: 7)]); + + var slices = new[] + { + bill.Lines[0].Total, + bill.Lines[1].Total, + bill.Lines[2].Total, + bill.StandingCharges[0].Total, + bill.ManualCosts.Total, + }; + var composed = CostAmount.Sum(slices); + + Assert.Equal(bill.Total.Cost, composed.Cost); + Assert.Equal(bill.Total.Status, composed.Status); + Assert.Equal(bill.Total.MissingPrices, composed.MissingPrices); + Assert.Equal(CostStatus.Partial, bill.Total.Status); + Assert.True(bill.Total.IncludesNotPriced); + Assert.Equal((3 * 30) + (2 * 50) + 36 + 100, bill.Total.Cost!.Value, 9); + Assert.Equal( + [(TariffComponent.UnitPrice, CostStatus.PriceGap, (int?)WaterMeter), (TariffComponent.UnitPrice, CostStatus.NotPriced, OilTank)], + bill.MissingPrices.Select(m => (m.Component, m.Reason, m.MeterId))); + } +} diff --git a/tests/Core.Tests/Analysis/Costing/CostCalculatorCoverageTests.cs b/tests/Core.Tests/Analysis/Costing/CostCalculatorCoverageTests.cs new file mode 100644 index 0000000..dcea0f4 --- /dev/null +++ b/tests/Core.Tests/Analysis/Costing/CostCalculatorCoverageTests.cs @@ -0,0 +1,424 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData; + +namespace MeterVault.Core.Tests.Analysis.Costing; + +/// +/// D-38 and brief §4.3: "not priced" (no tariff at all), "price gap" (a month without a price inside a priced +/// history), a free zero tariff, and an unknown quantity are four different answers — and none of them is a +/// fabricated zero. +/// +public sealed class CostCalculatorCoverageTests +{ + [Fact] + public void A_line_without_any_tariff_is_not_priced_and_says_from_when() + { + var book = Book(); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31)); + + var result = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 400), "L")]); + + Assert.All(result.Lines[0].Buckets, c => + { + Assert.Equal(CostStatus.NotPriced, c.Status); + Assert.Null(c.Cost); + }); + Assert.Equal(CostStatus.NotPriced, result.Total.Status); + Assert.Equal( + new MissingPrice(TariffComponent.UnitPrice, CostStatus.NotPriced, TariffScope.EnergyType, Oil, OilTank, D(2025, 1, 1), D(2025, 3, 1)), + Assert.Single(result.MissingPrices)); + Assert.False(Assert.Single(result.MissingPrices).IsCredit); + } + + [Fact] + public void A_not_priced_line_is_an_attention_item_not_a_reason_for_a_partial_bill() + { + // D-44: the seeded oil tank has no tariff; the bill is Strom + water, complete for everything priced. + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100)), Line(OilTank, Oil, Each(buckets, 400), "L")]); + + Assert.Equal(CostStatus.Priced, result.Total.Status); + Assert.True(result.Total.IncludesNotPriced); + Assert.Equal(60, result.Total.Cost!.Value, 9); + Assert.Equal(BucketStatus.Available, result.Total.Availability); + Assert.Equal(CostStatus.NotPriced, Assert.Single(result.MissingPrices).Reason); + } + + [Fact] + public void A_month_before_the_first_price_of_a_priced_scope_is_a_gap_and_the_year_is_partial() + { + var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1))); + var months = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 4, 30)); + var year = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 4, 30)); + + var monthly = Price(months, book, [Line(WaterMeter, Water, Each(months, 10), "m³")]); + var yearly = Price(year, book, [Line(WaterMeter, Water, Each(year, 10), "m³")]); + + Assert.Equal( + [CostStatus.PriceGap, CostStatus.PriceGap, CostStatus.Priced, CostStatus.Priced], + monthly.Lines[0].Buckets.Select(c => c.Status)); + Assert.Null(monthly.Lines[0].Buckets[0].Cost); + Assert.Equal(50, monthly.Lines[0].Buckets[2].Cost!.Value, 9); + + var total = yearly.Totals.Single(); + Assert.Equal(CostStatus.Partial, total.Status); + Assert.Equal(100, total.Cost!.Value, 9); + Assert.Equal( + new MissingPrice(TariffComponent.UnitPrice, CostStatus.PriceGap, TariffScope.EnergyType, Water, WaterMeter, D(2025, 1, 1), D(2025, 2, 1)), + Assert.Single(total.MissingPrices)); + } + + [Fact] + public void A_price_that_ended_leaves_a_gap_after_it() + { + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2024, 1, 1), D(2025, 1, 31))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]); + + Assert.Equal([CostStatus.Priced, CostStatus.PriceGap, CostStatus.PriceGap], result.Lines[0].Buckets.Select(c => c.Status)); + var missing = Assert.Single(result.MissingPrices); + Assert.Equal((D(2025, 2, 1), D(2025, 3, 1)), (missing.FirstMonth, missing.LastMonth)); + } + + [Fact] + public void An_explicit_zero_tariff_is_a_valid_zero() + { + var book = Book(TypePrice(1, Water, 0, "EUR/m3", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31)); + + var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 10), "m³")]); + + Assert.Equal(CostStatus.Priced, result.Total.Status); + Assert.Equal(0, result.Total.Cost); + Assert.Empty(result.MissingPrices); + } + + [Fact] + public void A_known_zero_quantity_needs_no_price() + { + // A meter not yet installed reads a known zero (D-24): the months before the first tariff are no gap. + var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31)); + + var result = Price(buckets, book, [Line(WaterMeter, Water, Monthly(buckets, m => m.Month < 3 ? 0 : 10), "m³")]); + + Assert.All(result.Lines[0].Buckets, c => Assert.Equal(CostStatus.Priced, c.Status)); + Assert.Equal([0d, 0d, 50d], result.Lines[0].Buckets.Select(c => Math.Round(c.Cost!.Value, 9))); + Assert.Empty(result.MissingPrices); + } + + [Fact] + public void A_month_without_data_needs_no_price() + { + // The seeded grid meter starts in September 2022, its first tariff too: January to August of that year have + // neither data nor a price, and asking for a tariff there would be noise — the cost is unknown for want of data. + var book = Book(TypePrice(1, Electricity, 0.16, "EUR/kWh", D(2022, 9, 1))); + var year = Buckets(BucketSize.Year, D(2022, 1, 1), D(2022, 12, 31)); + + var result = Price(year, book, [Line(Grid, Electricity, Monthly(year, m => m.Month < 10 ? null : 100))]); + + Assert.Empty(result.MissingPrices); + Assert.Equal(CostStatus.Priced, result.Total.Status); + Assert.Equal(BucketStatus.Partial, result.Total.Availability); + Assert.Equal(48, result.Total.Cost!.Value, 9); + + // A month on its own without data is unknown, not a gap. + var months = Buckets(BucketSize.Month, D(2022, 1, 1), D(2022, 12, 31)); + var monthly = Price(months, book, [Line(Grid, Electricity, Monthly(months, m => m.Month < 10 ? null : 100))]); + var january = monthly.Lines[0].Buckets[0]; + Assert.Equal(CostStatus.Priced, january.Status); + Assert.Equal(BucketStatus.Missing, january.Availability); + Assert.Null(january.Cost); + } + + [Fact] + public void A_line_without_data_or_tariff_reports_nothing_missing() + { + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28)); + + var result = Price(buckets, Book(), [Line(OilTank, Oil, [.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Unknown(p))], "L")]); + + Assert.Empty(result.MissingPrices); + Assert.False(result.Total.IncludesNotPriced); + Assert.Null(result.Total.Cost); + Assert.Equal(BucketStatus.Missing, result.Total.Availability); + } + + [Theory] + [InlineData(BucketStatus.Unresolved)] + [InlineData(BucketStatus.Pending)] + [InlineData(BucketStatus.Invalid)] + public void Data_that_cannot_be_cut_still_needs_its_price(BucketStatus status) + { + // Unresolved, pending or invalid quantities exist — they are only unknown per bucket — so a gap is still a gap. + var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31)); + + var result = Price(buckets, book, [Line(WaterMeter, Water, [CostQuantity.Unknown(CostCalculator.Parts(buckets).Single(), status)], "m³")]); + + Assert.Equal(CostStatus.PriceGap, result.Total.Status); + Assert.Equal(CostStatus.PriceGap, Assert.Single(result.MissingPrices).Reason); + } + + [Fact] + public void Zero_quantities_under_no_tariff_do_not_make_a_line_priced() + { + // Folding the whole line: zero months price nothing, the rest has no tariff — the line is still not priced. + var book = Book(); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31)); + + var result = Price(buckets, book, [Line(OilTank, Oil, Monthly(buckets, m => m.Month < 3 ? 0 : 400), "L")]); + + Assert.Equal(CostStatus.Priced, result.Lines[0].Buckets[0].Status); + Assert.Equal(CostStatus.NotPriced, result.Lines[0].Buckets[2].Status); + Assert.Equal(CostStatus.NotPriced, result.Lines[0].Total.Status); + Assert.Null(result.Lines[0].Total.Cost); + Assert.Equal(D(2025, 3, 1), Assert.Single(result.MissingPrices).FirstMonth); + } + + [Fact] + public void A_bucket_holding_an_interval_longer_than_a_month_is_priced_whole_at_one_price() + { + // Review R5 (A-16): a quarterly delta meter leaves every month unresolved; the year holds its intervals whole, + // and one price covers every month, so the year costs its quantity at that price. + var book = Book(TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31)); + var unresolved = CostCalculator.Parts(buckets).Select(p => CostQuantity.Unknown(p, BucketStatus.Unresolved)).ToList(); + var line = Line(Grid, Electricity, unresolved) with { Spans = [new CostSpan(0, 1200, BucketStatus.Available)] }; + + var result = Price(buckets, book, [line]); + + Assert.Equal(CostStatus.Priced, result.Total.Status); + Assert.Equal(BucketStatus.Available, result.Total.Availability); + Assert.Equal(120, result.Total.Cost!.Value, 9); + Assert.Empty(result.Lines[0].MonthsWithPriceChangeInsideInterval); + + // Without a span, the year stays unknown. + var unknown = Price(buckets, book, [Line(Grid, Electricity, unresolved)]); + Assert.Null(unknown.Total.Cost); + Assert.Equal(BucketStatus.Unresolved, unknown.Total.Availability); + } + + [Fact] + public void A_price_change_inside_a_whole_bucket_leaves_it_unknown_and_names_its_months() + { + var book = Book( + TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 1, 1), D(2025, 7, 31)), + TypePrice(2, Electricity, 0.20, "EUR/kWh", D(2025, 8, 1))); + var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31)); + var parts = CostCalculator.Parts(buckets); + var line = Line(Grid, Electricity, [.. parts.Select(p => CostQuantity.Unknown(p, BucketStatus.Unresolved))]) + with { Spans = [new CostSpan(0, 1200, BucketStatus.Available)] }; + + var result = Price(buckets, book, [line]); + + Assert.Null(result.Total.Cost); + Assert.Equal(BucketStatus.Unresolved, result.Total.Availability); + Assert.Equal([.. parts.Select(p => p.Month)], result.Lines[0].MonthsWithPriceChangeInsideInterval); + } + + [Fact] + public void A_span_prices_only_the_months_with_data_and_leaves_resolved_buckets_alone() + { + // A tank read from March: January and February have no data and no price, which does not stop the span. + var book = Book(TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 3, 1))); + var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31)); + var quantities = CostCalculator.Parts(buckets) + .Select(p => p.Month.Month < 3 ? CostQuantity.Unknown(p) : CostQuantity.Unknown(p, BucketStatus.Unresolved)) + .ToList(); + var line = Line(Grid, Electricity, quantities) with { Spans = [new CostSpan(0, 500, BucketStatus.Partial)] }; + + var result = Price(buckets, book, [line]); + + Assert.Equal(50, result.Total.Cost!.Value, 9); + Assert.Equal(BucketStatus.Partial, result.Total.Availability); + + // A bucket whose months are all resolved keeps its month-by-month price, span or not. + var months = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31)); + var resolved = Line(Grid, Electricity, Each(months, 10)) with { Spans = [new CostSpan(0, 999, BucketStatus.Available)] }; + Assert.Equal(1, Price(months, book, [resolved]).Total.Cost!.Value, 9); + } + + [Fact] + public void An_unknown_quantity_leaves_the_cost_unknown_but_priced() + { + // Price coverage and quantity availability are separate: March is priced but unmeasured. + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31)); + var quantities = Monthly(buckets, m => m.Month == 3 ? null : 100); + + var result = Price(buckets, book, [Line(Grid, Electricity, quantities)]); + + var march = result.Lines[0].Buckets[2]; + Assert.Equal(CostStatus.Priced, march.Status); + Assert.Equal(BucketStatus.Missing, march.Availability); + Assert.Null(march.Cost); + + Assert.Equal(BucketStatus.Partial, result.Total.Availability); + Assert.Equal(CostStatus.Priced, result.Total.Status); + Assert.Equal(60, result.Total.Cost!.Value, 9); + } + + [Theory] + [InlineData(BucketStatus.Unresolved)] + [InlineData(BucketStatus.Invalid)] + [InlineData(BucketStatus.Pending)] + public void A_quantity_that_is_not_a_number_is_never_priced_even_with_an_amount(BucketStatus status) + { + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31)); + var part = CostCalculator.Parts(buckets).Single(); + + var result = Price(buckets, book, [Line(Grid, Electricity, [new CostQuantity(part.FirstDay, part.EndDay, 100, status)])]); + + Assert.Null(result.Total.Cost); + Assert.Equal(status, result.Total.Availability); + } + + [Fact] + public void A_partial_quantity_keeps_its_partial_cost() + { + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31)); + var part = CostCalculator.Parts(buckets).Single(); + + var result = Price(buckets, book, [Line(Grid, Electricity, [CostQuantity.Known(part, 50, BucketStatus.Partial)])]); + + Assert.Equal(15, result.Total.Cost!.Value, 9); + Assert.Equal(BucketStatus.Partial, result.Total.Availability); + } + + [Fact] + public void A_part_without_a_quantity_reads_as_missing() + { + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28)); + + var result = Price(buckets, book, [Line(Grid, Electricity, [CostQuantity.Known(CostCalculator.Parts(buckets)[0], 100)])]); + + Assert.Equal(BucketStatus.Missing, result.Lines[0].Buckets[1].Availability); + Assert.Null(result.Lines[0].Buckets[1].Cost); + Assert.Equal(30, result.Total.Cost!.Value, 9); + } + + // ---- where the missing price belongs (D-52) ----------------------------------------------------- + + [Fact] + public void A_gap_is_suggested_in_the_scope_that_holds_the_price_history() + { + // The meter has its own history, so the gap is filled there, not at the type. + var book = Book(MeterPrice(1, Grid, 0.30, "EUR/kWh", D(2025, 2, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]); + + var missing = Assert.Single(result.MissingPrices); + Assert.Equal((TariffScope.Meter, (int?)Grid), (missing.Scope, missing.ScopeId)); + } + + [Fact] + public void A_gap_under_a_global_price_is_suggested_globally() + { + var book = Book(Tariff(1, TariffScope.Global, null, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2025, 2, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]); + + var missing = Assert.Single(result.MissingPrices); + Assert.Equal((TariffScope.Global, (int?)null, CostStatus.PriceGap), (missing.Scope, missing.ScopeId, missing.Reason)); + } + + // ---- D-34: feed-in ------------------------------------------------------------------------------- + + [Fact] + public void Feed_in_is_credited_on_the_export_line_only_and_kept_apart_from_the_charges() + { + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + FeedInPrice(2, Electricity, 0.08, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30)); + + var result = Price( + buckets, + book, + [Line(Grid, Electricity, Each(buckets, 300)), Line(Export, Electricity, Each(buckets, 200), kind: BillLineKind.FeedIn)]); + + var import = result.Lines[0].Total; + Assert.Equal(90, import.Usage!.Value, 9); + Assert.Null(import.FeedInCredit); + + var export = result.Lines[1].Total; + Assert.Equal(16, export.FeedInCredit!.Value, 9); + Assert.Null(export.Usage); + Assert.Null(export.Charges); + Assert.Equal(-16, export.Cost!.Value, 9); + + Assert.Equal(90, result.Total.Charges!.Value, 9); + Assert.Equal(16, result.Total.FeedInCredit!.Value, 9); + Assert.Equal(74, result.Total.Cost!.Value, 9); + } + + [Fact] + public void A_missing_feed_in_price_is_an_optional_credit_reported_only_where_there_is_export() + { + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31)); + + // No export in January and February, some in March. + var result = Price( + buckets, + book, + [Line(Grid, Electricity, Each(buckets, 100)), Line(Export, Electricity, Monthly(buckets, m => m.Month == 3 ? 50 : 0), kind: BillLineKind.FeedIn)]); + + var missing = Assert.Single(result.MissingPrices); + Assert.True(missing.IsCredit); + Assert.Equal((CostStatus.NotPriced, D(2025, 3, 1)), (missing.Reason, missing.FirstMonth)); + + // The bill stays priced: the credit is left out, the charges are complete. + Assert.Equal(CostStatus.Priced, result.Total.Status); + Assert.True(result.Total.IncludesNotPriced); + Assert.Equal(90, result.Total.Cost!.Value, 9); + Assert.Equal(0d, result.Total.FeedInCredit); + } + + [Fact] + public void A_feed_in_price_never_credits_a_billed_line() + { + // Generation and import are not export: only a FeedIn line earns the credit. + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + FeedInPrice(2, Electricity, 0.08, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 300))]); + + Assert.Null(result.Total.FeedInCredit); + Assert.Equal(90, result.Total.Cost!.Value, 9); + } + + [Fact] + public void A_feed_in_price_in_the_wrong_unit_is_a_mismatch_on_the_credit() + { + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + FeedInPrice(2, Electricity, 0.08, "EUR/m3", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30)); + + var result = Price( + buckets, + book, + [Line(Grid, Electricity, Each(buckets, 300)), Line(Export, Electricity, Each(buckets, 200), kind: BillLineKind.FeedIn)]); + + Assert.Equal(CostStatus.UnitMismatch, result.Lines[1].Total.Status); + Assert.Equal(CostStatus.Partial, result.Total.Status); + Assert.Equal(90, result.Total.Cost!.Value, 9); + var missing = Assert.Single(result.MissingPrices); + Assert.Equal((TariffComponent.FeedIn, CostStatus.UnitMismatch, (int?)2), (missing.Component, missing.Reason, missing.TariffId)); + } +} diff --git a/tests/Core.Tests/Analysis/Costing/CostCalculatorManualCostTests.cs b/tests/Core.Tests/Analysis/Costing/CostCalculatorManualCostTests.cs new file mode 100644 index 0000000..a858ccc --- /dev/null +++ b/tests/Core.Tests/Analysis/Costing/CostCalculatorManualCostTests.cs @@ -0,0 +1,107 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData; + +namespace MeterVault.Core.Tests.Analysis.Costing; + +/// +/// D-41: a manual cost is booked in full on its PeriodStart local day, when that day is inside the request and not +/// after today; PeriodEnd is informational. +/// +public sealed class CostCalculatorManualCostTests +{ + [Fact] + public void A_manual_cost_is_booked_in_full_on_its_start_day() + { + // A yearly oil delivery invoice dated 3 March: the whole amount in March, nothing spread over the year. + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 12, 31)); + + var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 3, 3), 1200, categoryId: 4, end: D(2025, 12, 31))]); + + var booking = Assert.Single(result.ManualCosts.Bookings); + Assert.Equal(new ManualCostBooking(1, 4, null, D(2025, 3, 3), 2, 1200, false), booking); + Assert.Equal(1200, result.Totals[2].Manual!.Value, 9); + Assert.All(result.Totals.Where((_, i) => i != 2), t => Assert.Null(t.Manual)); + Assert.Equal(1200, result.Total.Cost!.Value, 9); + Assert.Equal(CostStatus.Priced, result.Total.Status); + } + + [Fact] + public void A_manual_cost_lands_in_the_week_that_contains_its_day() + { + // Mon 31 March – Sun 6 April: a cost dated 31 March belongs to that week, not to a March bucket. + var weeks = Buckets(BucketSize.Week, D(2025, 3, 24), D(2025, 4, 13)); + + var result = Price(weeks, Book(), [], manual: [Manual(1, D(2025, 3, 31), 80)]); + + Assert.Equal(80, result.Totals.At(weeks, D(2025, 3, 31)).Manual!.Value, 9); + Assert.Equal(1, Assert.Single(result.ManualCosts.Bookings).BucketIndex); + } + + [Fact] + public void A_manual_cost_dated_after_today_is_not_booked_but_listed() + { + // The September bucket reaches the end of the month, but today is the 19th. + var buckets = new[] { Bucket(D(2025, 9, 1), D(2025, 10, 1)) }; + + var result = Price( + buckets, + Book(), + [], + today: D(2025, 9, 19), + manual: [Manual(1, D(2025, 9, 19), 50), Manual(2, D(2025, 9, 25), 70), Manual(3, D(2025, 8, 31), 90), Manual(4, D(2025, 10, 1), 30)]); + + Assert.Equal([1], result.ManualCosts.Bookings.Select(b => b.ManualCostId)); + Assert.Equal([2], result.ManualCosts.AfterTodayIds); + Assert.Equal(50, result.Total.Cost!.Value, 9); + } + + [Fact] + public void Manual_costs_outside_the_request_are_ignored() + { + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31)); + + var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 2, 28), 10), Manual(2, D(2025, 4, 1), 20)]); + + Assert.Empty(result.ManualCosts.Bookings); + Assert.Empty(result.ManualCosts.AfterTodayIds); + Assert.Null(result.Total.Cost); + } + + [Fact] + public void A_manual_cost_only_instance_still_has_a_cost() + { + // No meter at all: the Heizung manual costs alone make the bill (brief §11, manual-cost-only instance). + var buckets = Buckets(BucketSize.Month, D(2026, 1, 1), D(2026, 2, 28)); + + var result = Price(buckets, Book(), [], manual: [Manual(1, D(2026, 1, 1), 200), Manual(2, D(2026, 2, 1), 180)]); + + Assert.Equal([200d, 180d], result.Totals.Select(t => t.Cost!.Value)); + Assert.Equal(BucketStatus.Available, result.Total.Availability); + } + + [Fact] + public void A_manual_cost_in_another_currency_is_booked_and_flagged() + { + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31)); + + var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 3, 5), 40, currency: "USD"), Manual(2, D(2025, 3, 6), 60, currency: "€")]); + + Assert.Equal([true, false], result.ManualCosts.Bookings.Select(b => b.CurrencyMismatch)); + Assert.True(result.Total.Unverified); + Assert.Equal(100, result.Total.Cost!.Value, 9); + } + + [Fact] + public void Manual_costs_add_to_a_bill_exactly_once() + { + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))], manual: [Manual(1, D(2025, 2, 10), 25)]); + + Assert.Equal(30 + 30 + 25, result.Total.Cost!.Value, 9); + Assert.Equal(25, result.ManualCosts.Total.Manual!.Value, 9); + Assert.Equal(55, result.Totals[1].Cost!.Value, 9); + } +} diff --git a/tests/Core.Tests/Analysis/Costing/CostCalculatorPricingTests.cs b/tests/Core.Tests/Analysis/Costing/CostCalculatorPricingTests.cs new file mode 100644 index 0000000..180048f --- /dev/null +++ b/tests/Core.Tests/Analysis/Costing/CostCalculatorPricingTests.cs @@ -0,0 +1,268 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData; + +namespace MeterVault.Core.Tests.Analysis.Costing; + +/// +/// D-36 and D-37: every month is priced with the price in effect on its 15th, whatever the bucket size, and a price +/// applies only in a unit that fits the meter's normalized unit. +/// +public sealed class CostCalculatorPricingTests +{ + // ---- D-36: the 15th --------------------------------------------------------------------------- + + [Fact] + public void A_month_is_priced_with_the_price_in_effect_on_its_15th() + { + // 0.30 from January, 0.40 from 16 March, 0.50 from 15 May: March keeps the old price (the change comes a day + // too late), May already has the new one (in effect on the 15th itself). + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 3, 16)), + TypePrice(3, Electricity, 0.50, "EUR/kWh", D(2025, 5, 15))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 5, 31)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]); + + var costs = result.Lines[0].Buckets.Select(c => c.Cost!.Value).ToList(); + Assert.Equal([30, 30, 30, 40, 50], costs.Select(c => Math.Round(c, 9))); + Assert.All(result.Lines[0].Buckets, c => Assert.Equal(CostStatus.Priced, c.Status)); + Assert.Equal(180, result.Total.Cost!.Value, 9); + } + + [Fact] + public void A_price_ending_on_the_14th_leaves_its_month_to_the_next_price() + { + // The old price's ValidTo is inclusive but ends before the 15th; the successor starts on the 15th. + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1), D(2025, 2, 14)), + TypePrice(2, Electricity, 0.35, "EUR/kWh", D(2025, 2, 15))); + var buckets = Buckets(BucketSize.Month, D(2025, 2, 1), D(2025, 2, 28)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]); + + Assert.Equal(35, result.Total.Cost!.Value, 9); + } + + [Fact] + public void A_week_straddling_a_month_is_priced_with_each_month_s_own_price() + { + // Mon 27 Jan – Sun 2 Feb 2025: five January days at 0.30, two February days at 0.40, 10 kWh a day. + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 2, 1))); + var buckets = Buckets(BucketSize.Week, D(2025, 1, 27), D(2025, 2, 2)); + Assert.Single(buckets); + + var parts = CostCalculator.Parts(buckets); + Assert.Equal([(D(2025, 1, 27), D(2025, 2, 1)), (D(2025, 2, 1), D(2025, 2, 3))], parts.Select(p => (p.FirstDay, p.EndDay))); + + var result = Price(buckets, book, [Line(Grid, Electricity, Daily(buckets, _ => 10))]); + + Assert.Equal((50 * 0.30) + (20 * 0.40), result.Total.Cost!.Value, 9); + Assert.Equal(CostStatus.Priced, result.Total.Status); + } + + [Theory] + [InlineData(BucketSize.Day)] + [InlineData(BucketSize.Week)] + [InlineData(BucketSize.Month)] + [InlineData(BucketSize.Year)] + public void The_bucket_size_never_changes_the_total(BucketSize size) + { + // Three price changes (one mid-month), a yearly standing charge and a feed-in credit over 2024 (a leap year): + // day, week, month and year buckets must all give the same bill, and each equal the sum of its month buckets. + var book = Book( + TypePrice(1, Electricity, 0.31, "EUR/kWh", D(2023, 1, 1)), + TypePrice(2, Electricity, 0.28, "EUR/kWh", D(2024, 4, 20)), + TypePrice(3, Electricity, 26.5, "ct/kWh", D(2024, 9, 1)), + FeedInPrice(4, Electricity, 8.2, "ct/kWh", D(2020, 1, 1)), + BasePrice(5, TariffScope.EnergyType, Electricity, 180, "EUR/Jahr", D(2020, 1, 1))); + static double Import(DateOnly d) => 5 + (d.DayOfYear % 7); + static double Exported(DateOnly d) => d.Month is >= 4 and <= 9 ? 3 + (d.Day % 4) : 0.5; + + CostResult PriceWith(BucketSize bucketSize) + { + var buckets = Buckets(bucketSize, D(2024, 1, 1), D(2024, 12, 31)); + return Price( + buckets, + book, + [Line(Grid, Electricity, Daily(buckets, Import)), Line(Export, Electricity, Daily(buckets, Exported), kind: BillLineKind.FeedIn)], + standing: [StandingChargeScope.ForEnergyType(Electricity, new ServicePeriod(D(2020, 1, 1)))]); + } + + var months = PriceWith(BucketSize.Month); + var other = PriceWith(size); + + Assert.Equal(months.Total.Cost!.Value, other.Total.Cost!.Value, 6); + Assert.Equal(months.Total.Usage!.Value, other.Total.Usage!.Value, 6); + Assert.Equal(months.Total.StandingCharge!.Value, other.Total.StandingCharge!.Value, 6); + Assert.Equal(180, other.Total.StandingCharge!.Value, 6); + Assert.Equal(months.Total.FeedInCredit!.Value, other.Total.FeedInCredit!.Value, 6); + Assert.Equal(months.Totals.Sum(t => t.Cost!.Value), other.Totals.Sum(t => t.Cost!.Value), 6); + Assert.Equal(CostStatus.Priced, other.Total.Status); + } + + [Fact] + public void A_year_is_the_sum_of_its_months_not_a_year_priced_from_one_sample() + { + // The old engine priced a year bucket at the 1 July price × the whole year's quantity. + var book = Book( + TypePrice(1, Electricity, 0.20, "EUR/kWh", D(2025, 1, 1)), + TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 7, 1))); + var year = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31)); + var months = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 12, 31)); + Assert.Equal(12, CostCalculator.Parts(year).Count); + + var yearly = Price(year, book, [Line(Grid, Electricity, Each(year, 100))]); + var monthly = Price(months, book, [Line(Grid, Electricity, Each(months, 100))]); + + Assert.Equal((6 * 100 * 0.20) + (6 * 100 * 0.40), yearly.Total.Cost!.Value, 9); + Assert.Equal(monthly.Totals.Sum(t => t.Cost!.Value), yearly.Totals.Single().Cost!.Value, 9); + } + + [Fact] + public void A_year_to_date_bucket_ends_in_a_part_of_the_current_month() + { + var buckets = ToDate(PeriodPreset.YearToDate, AnalysisClock.At(AnalysisClock.Berlin, 2025, 9, 19, 14, 0), BucketSize.Year); + + var parts = CostCalculator.Parts(buckets); + + Assert.Equal(9, parts.Count); + Assert.Equal((D(2025, 9, 1), D(2025, 9, 20)), (parts[^1].FirstDay, parts[^1].EndDay)); + Assert.All(parts, p => Assert.Equal(0, p.BucketIndex)); + } + + // ---- D-37: units ------------------------------------------------------------------------------ + + [Fact] + public void A_price_in_cents_per_kWh_is_scaled_to_the_currency() + { + var book = Book(TypePrice(1, Electricity, 30, "ct/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]); + + Assert.Equal(30, result.Total.Cost!.Value, 9); + Assert.False(result.Total.Unverified); + } + + [Fact] + public void A_price_per_MWh_prices_a_kWh_meter() + { + var book = Book(TypePrice(1, Electricity, 250, "EUR/MWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 400))]); + + Assert.Equal(100, result.Total.Cost!.Value, 9); + } + + [Theory] + [InlineData("L", 1000, 950)] + [InlineData("m³", 1, 950)] + [InlineData("m3", 2, 1900)] + public void A_price_per_100_litres_prices_litres_and_cubic_metres(string unit, double quantity, double expected) + { + var book = Book(TypePrice(1, Oil, 95, "EUR/100L", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31)); + + var result = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, quantity), unit)]); + + Assert.Equal(expected, result.Total.Cost!.Value, 9); + } + + [Fact] + public void A_price_whose_unit_does_not_fit_makes_the_cost_unavailable_for_unit() + { + // A kWh price on a water meter: the old engine charged m³ at the electricity price. + var book = Book(Tariff(7, TariffScope.EnergyType, Water, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 4, 30)); + + var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 12), "m³")]); + + var line = result.Lines[0]; + Assert.All(line.Buckets, c => + { + Assert.Equal(CostStatus.UnitMismatch, c.Status); + Assert.Null(c.Cost); + }); + var missing = Assert.Single(result.MissingPrices); + Assert.Equal( + new MissingPrice(TariffComponent.UnitPrice, CostStatus.UnitMismatch, TariffScope.EnergyType, Water, WaterMeter, D(2025, 3, 1), D(2025, 4, 1), 7, TariffUnitIssue.IncompatibleUnit), + missing); + Assert.Null(result.Total.Cost); + } + + [Fact] + public void A_mismatching_meter_price_does_not_fall_back_to_the_type_price() + { + // The user's own meter price wins by precedence; applying the type's instead would hide the broken override. + var book = Book( + TypePrice(1, Water, 5, "EUR/m3", D(2025, 1, 1)), + MeterPrice(2, WaterMeter, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31)); + + var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 12), "m³")]); + + Assert.Equal(CostStatus.UnitMismatch, result.Total.Status); + Assert.Equal(2, Assert.Single(result.MissingPrices).TariffId); + } + + [Fact] + public void A_price_in_another_currency_is_a_mismatch() + { + var book = Book(TypePrice(3, Electricity, 0.25, "USD/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]); + + Assert.Equal(CostStatus.UnitMismatch, result.Total.Status); + Assert.Equal(TariffUnitIssue.CurrencyMismatch, Assert.Single(result.MissingPrices).Issue); + } + + [Fact] + public void An_instance_in_another_currency_prices_its_own_currency() + { + var book = TariffBook.Create([TypePrice(3, Electricity, 25, "Rp./kWh", D(2025, 1, 1))], "CHF"); + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]); + + Assert.Equal("CHF", result.Currency); + Assert.Equal(25, result.Total.Cost!.Value, 9); + } + + [Fact] + public void An_unreadable_unit_applies_at_face_value_with_a_warning() + { + var book = Book(TypePrice(9, Electricity, 0.30, "pauschal", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 4, 30)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]); + + Assert.Equal(60, result.Total.Cost!.Value, 9); + Assert.Equal(CostStatus.Priced, result.Total.Status); + Assert.True(result.Total.Unverified); + Assert.All(result.Lines[0].Buckets, c => Assert.True(c.Unverified)); + Assert.Equal(new TariffWarning(9, TariffComponent.UnitPrice, TariffUnitIssue.Unparseable, Grid, D(2025, 3, 1)), Assert.Single(result.Warnings)); + } + + [Fact] + public void A_line_is_priced_in_the_unit_it_is_given_not_a_raw_unit() + { + // A burner converted to litres by a fixed rate is priced per litre; its raw unit (h) would mismatch. + var book = Book(Tariff(1, TariffScope.Meter, OilTank, TariffComponent.UnitPrice, 1.10, "EUR/L", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31)); + + var litres = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 100), "L")]); + var hours = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 100), "h")]); + + Assert.Equal(110, litres.Total.Cost!.Value, 9); + Assert.Equal(CostStatus.UnitMismatch, hours.Total.Status); + } +} diff --git a/tests/Core.Tests/Analysis/Costing/CostCalculatorStandingChargeTests.cs b/tests/Core.Tests/Analysis/Costing/CostCalculatorStandingChargeTests.cs new file mode 100644 index 0000000..63f14cd --- /dev/null +++ b/tests/Core.Tests/Analysis/Costing/CostCalculatorStandingChargeTests.cs @@ -0,0 +1,347 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData; + +namespace MeterVault.Core.Tests.Analysis.Costing; + +/// +/// D-40: a standing charge accrues per local day over its scope's service period — once per scope, never once per +/// meter with data (the old engine charged a type's base price for every meter of the type, and a whole month for a +/// month that had barely begun). +/// +public sealed class CostCalculatorStandingChargeTests +{ + private static readonly ServicePeriod Always = new(D(2000, 1, 1)); + + [Fact] + public void A_type_standing_charge_accrues_once_per_day_however_many_meters_are_billed() + { + const int second = 13; + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + BasePrice(2, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30)); + + var result = Price( + buckets, + book, + [Line(Grid, Electricity, Each(buckets, 100), service: Always), Line(second, Electricity, Each(buckets, 50), service: Always)], + standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]); + + var row = Assert.Single(result.StandingCharges); + Assert.Equal((TariffScope.EnergyType, (int?)Electricity), (row.Scope, row.ScopeId)); + Assert.Equal(12, row.Total.StandingCharge!.Value, 9); + + // The meters' own lines carry no share of it. + Assert.All(result.Lines, l => Assert.Null(l.Total.StandingCharge)); + Assert.Equal(45 + 12, result.Total.Cost!.Value, 9); + } + + [Fact] + public void A_month_to_date_accrues_the_days_up_to_today_only() + { + // 19 September at 14:00: nineteen days of a 30-day month, not the whole month. + var now = AnalysisClock.At(AnalysisClock.Berlin, 2025, 9, 19, 14, 0); + var buckets = ToDate(PeriodPreset.MonthToDate, now, BucketSize.Day); + var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "EUR/month", D(2025, 1, 1))); + + var result = Price(buckets, book, [], today: D(2025, 9, 19), standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]); + + Assert.Equal(19, result.Totals.Count); + Assert.All(result.Totals, t => Assert.Equal(1, t.StandingCharge!.Value, 9)); + Assert.Equal(19, result.Total.Cost!.Value, 9); + } + + [Fact] + public void In_a_zone_behind_UTC_the_local_month_and_day_decide() + { + // 22:00 on 30 September in New York is already 1 October in UTC: month to date is still September, all 30 days. + var now = AnalysisClock.At(AnalysisClock.NewYork, 2025, 9, 30, 22, 0); + var period = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, now, AnalysisClock.NewYork); + var buckets = BucketPlanner.Plan(period, BucketSize.Month).Buckets; + var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "USD/month", D(2025, 1, 1))); + + var result = CostCalculator.Calculate(new CostRequest( + buckets, + PeriodResolver.LocalDate(now, AnalysisClock.NewYork), + TariffBook.Create(book.Tariffs, "USD"), + [], + [StandingChargeScope.ForEnergyType(Electricity, Always)])); + + Assert.Equal((D(2025, 9, 1), D(2025, 10, 1)), (CostCalculator.Parts(buckets).Single().FirstDay, CostCalculator.Parts(buckets).Single().EndDay)); + Assert.Equal(30, result.Total.StandingCharge!.Value, 9); + } + + [Fact] + public void Nothing_accrues_after_today_even_inside_a_bucket() + { + var buckets = new[] { Bucket(D(2025, 9, 1), D(2025, 10, 1)) }; + var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "EUR/month", D(2025, 1, 1))); + + var result = Price(buckets, book, [], today: D(2025, 9, 10), standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]); + + Assert.Equal(10, result.Total.StandingCharge!.Value, 9); + } + + [Fact] + public void A_meter_standing_charge_accrues_on_its_line_from_install_to_retire() + { + // Installed 10 March, retired 20 June (inclusive): 22/31 of March, April, May, 20/30 of June, nothing in July. + var service = ServicePeriod.ForMeter(D(2025, 3, 10), D(2025, 6, 20), firstDataDay: D(2025, 3, 12)); + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + BasePrice(2, TariffScope.Meter, Grid, 10, "EUR/Monat", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 7, 31)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 0), service: service)]); + + var standing = result.Lines[0].Buckets.Select(c => c.StandingCharge!.Value).ToList(); + Assert.Equal(10 * 22 / 31d, standing[0], 9); + Assert.Equal(10, standing[1], 9); + Assert.Equal(10, standing[2], 9); + Assert.Equal(10 * 20 / 30d, standing[3], 9); + Assert.Equal(0, standing[4], 9); + Assert.Empty(result.StandingCharges); + } + + [Fact] + public void A_service_period_ignores_reading_gaps() + { + // A meter with a month of missing data still pays its standing charge for that month. + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + BasePrice(2, TariffScope.Meter, Grid, 10, "EUR/Monat", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31)); + + var result = Price(buckets, book, [Line(Grid, Electricity, Monthly(buckets, m => m.Month == 2 ? null : 100), service: Always)]); + + var february = result.Lines[0].Buckets[1]; + Assert.Equal(10, february.StandingCharge!.Value, 9); + Assert.Null(february.Usage); + Assert.Equal(10, february.Cost!.Value, 9); + Assert.Equal(BucketStatus.Partial, february.Availability); + } + + [Fact] + public void A_yearly_charge_accrues_by_the_days_of_the_year() + { + var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 366, "EUR/Jahr", D(2020, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2024, 1, 1), D(2024, 12, 31)); + + var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]); + + Assert.Equal(31, result.Totals[0].StandingCharge!.Value, 9); + Assert.Equal(29, result.Totals[1].StandingCharge!.Value, 9); + Assert.Equal(366, result.Total.StandingCharge!.Value, 9); + } + + [Fact] + public void A_quarterly_charge_accrues_by_the_days_of_its_calendar_quarter() + { + // Q1 2025 has 90 days, Q2 91: 90 EUR per quarter is 1 EUR a day in Q1 and 90/91 in Q2. + var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 90, "EUR/Quartal", D(2020, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 6, 30)); + + var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]); + + var charges = result.Totals.Select(t => t.StandingCharge!.Value).ToList(); + Assert.Equal(31, charges[0], 9); + Assert.Equal(28, charges[1], 9); + Assert.Equal(31, charges[2], 9); + Assert.Equal(90 * 30 / 91d, charges[3], 9); + Assert.Equal(180, result.Total.StandingCharge!.Value, 9); + } + + [Fact] + public void A_daily_charge_is_charged_per_day() + { + var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 0.5, "EUR/Tag", D(2020, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 2, 1), D(2025, 2, 28)); + + var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]); + + Assert.Equal(14, result.Total.StandingCharge!.Value, 9); + } + + [Theory] + [InlineData(10, 20)] + [InlineData(16, 10)] + public void A_standing_charge_price_change_takes_effect_by_the_15th_rule(int changeDay, double marchPrice) + { + // 10 EUR/month, then 20 from the change day: a change up to the 15th prices all of March at 20 (1–9 March + // included), one on the 16th leaves March at 10. April is 20 either way — per day, month by month. + var book = Book( + BasePrice(1, TariffScope.EnergyType, Electricity, 10, "EUR/Monat", D(2025, 1, 1)), + BasePrice(2, TariffScope.EnergyType, Electricity, 20, "EUR/Monat", D(2025, 3, changeDay))); + var weeks = Buckets(BucketSize.Week, D(2025, 2, 24), D(2025, 4, 6)); + + var result = Price(weeks, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]); + + // Mon 24 February – Sun 2 March: five February days at 10/28, two March days at the March price. + var straddling = result.Totals.At(weeks, D(2025, 2, 24)); + Assert.Equal((5 * 10 / 28d) + (2 * marchPrice / 31d), straddling.StandingCharge!.Value, 9); + Assert.Equal((5 * 10 / 28d) + marchPrice + (6 * 20 / 30d), result.Total.StandingCharge!.Value, 9); + } + + [Fact] + public void A_global_standing_charge_is_its_own_row() + { + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + BasePrice(2, TariffScope.Global, null, 5, "EUR/Monat", D(2025, 1, 1)), + BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30)); + + var result = Price( + buckets, + book, + [Line(Grid, Electricity, Each(buckets, 100), service: Always)], + standing: [StandingChargeScope.ForEnergyType(Electricity, Always), StandingChargeScope.Global(Always)]); + + Assert.Equal([TariffScope.EnergyType, TariffScope.Global], result.StandingCharges.Select(r => r.Scope)); + Assert.Equal(5, result.StandingCharges[1].Total.StandingCharge!.Value, 9); + + // Each scope's charge is its own: the type's does not replace the global one. + Assert.Equal(30 + 12 + 5, result.Total.Cost!.Value, 9); + } + + [Fact] + public void A_meter_s_own_standing_charge_comes_on_top_of_the_type_s() + { + // A heat pump's meter fee next to the supply contract's base price. + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + BasePrice(2, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)), + MeterPrice(3, Heater, 0.22, "EUR/kWh", D(2025, 1, 1)), + BasePrice(4, TariffScope.Meter, Heater, 8, "EUR/Monat", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30)); + + var result = Price( + buckets, + book, + [Line(Grid, Electricity, Each(buckets, 200), service: Always), Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice, service: Always)], + standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]); + + Assert.Equal(8, result.Lines[1].Total.StandingCharge!.Value, 9); + Assert.Equal(22 + 8, result.Lines[1].Total.Cost!.Value, 9); + Assert.Equal(60 + 22 + 8 + 12, result.Total.Cost!.Value, 9); + } + + [Fact] + public void A_meter_fee_on_a_meter_without_a_line_is_its_own_row_on_that_meter() + { + // Review R3 (A-18): a PV meter's fee, on a bill that prices only the grid import. It accrues as the PV meter's + // own row, from its install date, not on some other meter's line and not nowhere. + var book = Book( + TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), + BasePrice(2, TariffScope.Meter, Export, 2, "EUR/Monat", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31)); + + var result = Price( + buckets, + book, + [Line(Grid, Electricity, Each(buckets, 100), service: Always)], + standing: [StandingChargeScope.ForMeter(Export, new ServicePeriod(D(2025, 2, 1)))]); + + var row = Assert.Single(result.StandingCharges); + Assert.Equal((TariffScope.Meter, (int?)Export), (row.Scope, row.ScopeId)); + Assert.Equal([0d, 2d, 2d], row.Buckets.Select(b => Math.Round(b.Cost!.Value, 9))); + Assert.Equal(90 + 4, result.Total.Cost!.Value, 9); + + // A gap in the fee's history names the meter it belongs to. + var gap = Price( + Buckets(BucketSize.Month, D(2024, 12, 1), D(2024, 12, 31)), + book, + [], + standing: [StandingChargeScope.ForMeter(Export, new ServicePeriod(D(2024, 1, 1)))]); + Assert.Equal((TariffScope.Meter, (int?)Export, (int?)Export), (gap.MissingPrices[0].Scope, gap.MissingPrices[0].ScopeId, gap.MissingPrices[0].MeterId)); + } + + [Fact] + public void A_scope_without_a_base_price_adds_no_row() + { + var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30)); + + var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always), StandingChargeScope.Global(Always)]); + + Assert.Empty(result.StandingCharges); + Assert.Empty(result.MissingPrices); + } + + [Fact] + public void A_scope_out_of_service_accrues_nothing() + { + var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30)); + + var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, null)]); + + Assert.Equal(0, Assert.Single(result.StandingCharges).Total.StandingCharge); + Assert.Equal(CostStatus.Priced, result.Total.Status); + } + + [Fact] + public void A_gap_in_a_standing_charge_history_is_unavailable_for_those_months() + { + var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 3, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 4, 30)); + + var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]); + + var row = Assert.Single(result.StandingCharges); + Assert.Equal([CostStatus.PriceGap, CostStatus.PriceGap, CostStatus.Priced, CostStatus.Priced], row.Buckets.Select(c => c.Status)); + Assert.Equal(CostStatus.Partial, row.Total.Status); + Assert.Equal(24, row.Total.StandingCharge!.Value, 9); + Assert.Equal( + new MissingPrice(TariffComponent.BasePrice, CostStatus.PriceGap, TariffScope.EnergyType, Electricity, null, D(2025, 1, 1), D(2025, 2, 1)), + Assert.Single(result.MissingPrices)); + } + + [Fact] + public void An_unreadable_standing_charge_unit_is_taken_per_month_with_a_warning() + { + var book = Book(BasePrice(4, TariffScope.EnergyType, Electricity, 12, "pauschal", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30)); + + var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]); + + Assert.Equal(12, result.Total.StandingCharge!.Value, 9); + Assert.True(result.Total.Unverified); + Assert.Equal(TariffUnitIssue.Unparseable, Assert.Single(result.Warnings).Issue); + } + + [Fact] + public void A_standing_charge_in_an_unsupported_period_is_a_mismatch() + { + var book = Book(BasePrice(4, TariffScope.EnergyType, Electricity, 24, "EUR/2 Monate", D(2025, 1, 1))); + var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30)); + + var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]); + + Assert.Equal(CostStatus.UnitMismatch, result.Total.Status); + var missing = Assert.Single(result.MissingPrices); + Assert.Equal((4, TariffUnitIssue.UnsupportedPeriod), (missing.TariffId, missing.Issue)); + } + + [Fact] + public void The_scope_s_service_period_spans_its_meters() + { + // A retired grid meter and its successor: the type is in service from the first install to now. + var retired = ServicePeriod.ForMeter(D(2020, 5, 1), D(2023, 6, 30), null); + var successor = ServicePeriod.ForMeter(null, null, D(2023, 7, 3)); + var neverInService = ServicePeriod.ForMeter(null, null, null); + + var span = ServicePeriod.Span([retired, successor, neverInService]); + + Assert.Null(neverInService); + Assert.Equal(D(2020, 5, 1), span!.FirstDay); + Assert.Null(span.LastDay); + Assert.Equal(D(2023, 6, 30), ServicePeriod.Span([retired])!.LastDay); + Assert.Null(ServicePeriod.Span([])); + Assert.Null(ServicePeriod.ForMeter(D(2024, 1, 1), D(2023, 1, 1), null)); + } +} diff --git a/tests/Core.Tests/Analysis/Costing/CostingTariffBookTests.cs b/tests/Core.Tests/Analysis/Costing/CostingTariffBookTests.cs new file mode 100644 index 0000000..8075bd3 --- /dev/null +++ b/tests/Core.Tests/Analysis/Costing/CostingTariffBookTests.cs @@ -0,0 +1,138 @@ +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Costing; +using MeterVault.Core.Domain; +using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData; + +namespace MeterVault.Core.Tests.Analysis.Costing; + +/// +/// The tariff book resolves exactly like (meter over type over global, latest start within +/// a scope, both ends inclusive) — but deterministically on ties — and answers the D-35/D-38 questions. +/// +public sealed class CostingTariffBookTests +{ + private static readonly Tariff[] History = + [ + Tariff(1, TariffScope.Global, null, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2023, 1, 1)), + Tariff(2, TariffScope.Global, 99, TariffComponent.UnitPrice, 0.31, "EUR/kWh", D(2023, 6, 1)), + TypePrice(3, Electricity, 0.40, "EUR/kWh", D(2023, 3, 1), D(2023, 12, 31)), + TypePrice(4, Electricity, 0.42, "EUR/kWh", D(2023, 9, 1), D(2023, 10, 31)), + MeterPrice(5, Grid, 0.50, "EUR/kWh", D(2023, 5, 1), D(2023, 5, 31)), + TypePrice(6, Water, 5, "EUR/m3", D(2023, 1, 1)), + Tariff(7, TariffScope.EnergyType, Electricity, TariffComponent.BasePrice, 12, "EUR/Monat", D(2023, 1, 1)), + ]; + + public static TheoryData Dates => new() + { + { Grid, Electricity, 2, 15 }, + { Grid, Electricity, 4, 15 }, + { Grid, Electricity, 5, 15 }, + { Grid, Electricity, 5, 31 }, + { Grid, Electricity, 6, 1 }, + { Grid, Electricity, 9, 15 }, + { Grid, Electricity, 11, 1 }, + { Grid, Electricity, 12, 31 }, + { WaterMeter, Water, 7, 15 }, + { 77, 5, 7, 15 }, + }; + + [Theory] + [MemberData(nameof(Dates))] + public void Resolves_like_the_tariff_resolver(int meterId, int energyTypeId, int month, int day) + { + var book = Book(History); + var date = D(2023, month, day); + + foreach (var component in new[] { TariffComponent.UnitPrice, TariffComponent.BasePrice, TariffComponent.FeedIn }) + { + var expected = TariffResolver.Resolve(History, component, meterId, energyTypeId, date); + Assert.Same(expected, book.Resolve(component, meterId, energyTypeId, date)); + } + } + + [Fact] + public void A_newer_price_in_the_same_scope_overrides_an_older_one_that_still_covers_the_date() + { + var book = Book(History); + + Assert.Equal(4, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2023, 9, 15))!.Id); + Assert.Equal(3, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2023, 11, 15))!.Id); + Assert.Equal(2, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2024, 1, 15))!.Id); + } + + [Fact] + public void A_tie_on_scope_and_start_goes_to_the_later_entry_whatever_the_load_order() + { + var older = TypePrice(10, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)); + var newer = TypePrice(11, Electricity, 0.35, "EUR/kWh", D(2025, 1, 1)); + + Assert.Same(newer, Book(older, newer).Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2025, 3, 15))); + Assert.Same(newer, Book(newer, older).Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2025, 3, 15))); + } + + [Fact] + public void Resolving_in_one_scope_never_falls_back_to_another() + { + var book = Book(History); + + Assert.Equal(7, book.ResolveInScope(TariffComponent.BasePrice, TariffScope.EnergyType, Electricity, D(2024, 1, 15))!.Id); + Assert.Null(book.ResolveInScope(TariffComponent.BasePrice, TariffScope.Global, null, D(2024, 1, 15))); + Assert.Null(book.ResolveInScope(TariffComponent.UnitPrice, TariffScope.Meter, Grid, D(2023, 6, 15))); + Assert.Equal(2, book.ResolveInScope(TariffComponent.UnitPrice, TariffScope.Global, 12345, D(2024, 1, 15))!.Id); + } + + [Fact] + public void A_scope_is_priced_when_it_has_a_tariff_at_any_date() + { + var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2030, 1, 1))); + + Assert.True(book.HasAny(TariffComponent.UnitPrice, WaterMeter, Water)); + Assert.False(book.HasAny(TariffComponent.UnitPrice, Grid, Electricity)); + Assert.False(book.HasAny(TariffComponent.FeedIn, WaterMeter, Water)); + } + + [Fact] + public void A_tariff_that_ends_before_it_starts_is_left_out() + { + var broken = TypePrice(1, Water, 5, "EUR/m3", D(2025, 6, 1), D(2025, 1, 31)); + + var book = Book(broken); + + Assert.Empty(book.Tariffs); + Assert.False(book.HasAny(TariffComponent.UnitPrice, WaterMeter, Water)); + } + + [Fact] + public void An_own_price_is_known_per_month_by_the_15th() + { + var book = Book(MeterPrice(1, Heater, 0.22, "EUR/kWh", D(2025, 3, 16)), TypePrice(2, Electricity, 0.30, "EUR/kWh", D(2020, 1, 1))); + + Assert.True(book.HasMeterScopedUnitPrice(Heater)); + Assert.False(book.HasMeterScopedUnitPrice(Grid)); + Assert.False(book.HasOwnUnitPrice(Heater, D(2025, 3, 1))); + Assert.True(book.HasOwnUnitPrice(Heater, D(2025, 4, 30))); + Assert.False(book.HasOwnUnitPrice(Grid, D(2025, 4, 1))); + } + + [Fact] + public void Units_are_checked_against_the_instance_currency() + { + var eur = TypePrice(1, Electricity, 30, "ct/kWh", D(2025, 1, 1)); + var chf = TypePrice(2, Electricity, 0.3, "CHF/kWh", D(2025, 1, 1)); + var book = TariffBook.Create([eur, chf], "€"); + + Assert.Equal(0.3, book.Applicability(eur, "kWh").Convert(30), 12); + Assert.Equal(TariffUnitIssue.CurrencyMismatch, book.Applicability(chf, "kWh").Issue); + Assert.Equal(TariffUnitIssue.IncompatibleUnit, book.Applicability(eur, "m³").Issue); + Assert.Same(book.UnitOf(eur), book.UnitOf(eur)); + Assert.Equal(BillingPeriod.Month, book.Accrual(Tariff(3, TariffScope.Global, null, TariffComponent.BasePrice, 5, "EUR/Monat", D(2025, 1, 1))).Period); + } + + [Fact] + public void The_price_date_is_the_15th_of_the_month() + { + Assert.Equal(D(2024, 2, 15), TariffBook.PriceDate(D(2024, 2, 29))); + Assert.Equal(D(2025, 12, 15), TariffBook.PriceDate(D(2025, 12, 1))); + } +} diff --git a/tests/Core.Tests/Analysis/Costing/CostingTestData.cs b/tests/Core.Tests/Analysis/Costing/CostingTestData.cs new file mode 100644 index 0000000..2c6090f --- /dev/null +++ b/tests/Core.Tests/Analysis/Costing/CostingTestData.cs @@ -0,0 +1,153 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Costing; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Tests.Analysis.Costing; + +/// +/// Builders for the cost-calculator tests: real buckets from the period resolver and bucket planner (Berlin), tariffs, +/// and quantities per part. Nothing here reads the wall clock. +/// +internal static class CostingTestData +{ + public const int Electricity = 1; + public const int Water = 2; + public const int Oil = 3; + + public const int Grid = 10; + public const int Heater = 11; + public const int Export = 12; + public const int WaterMeter = 20; + public const int OilTank = 30; + + /// A "now" long after every range the tests price, so custom ranges resolve as complete. + public static readonly DateTimeOffset FarFuture = AnalysisClock.Utc(2040, 1, 1); + + public static DateOnly D(int year, int month, int day) => new(year, month, day); + + /// The buckets of the complete range (inclusive), as the planner cuts them. + public static IReadOnlyList Buckets(BucketSize size, DateOnly first, DateOnly last) + { + var period = PeriodResolver.Resolve(PeriodPreset.Custom, first, last, FarFuture, AnalysisClock.Berlin); + return Plan(period, size); + } + + /// The buckets of a to-date preset at (Berlin). + public static IReadOnlyList ToDate(PeriodPreset preset, DateTimeOffset now, BucketSize size) => + Plan(PeriodResolver.Resolve(preset, null, null, now, AnalysisClock.Berlin), size); + + /// A bucket spelled out, for the cases a planner never produces (a bucket reaching past today). + public static AnalysisBucket Bucket(DateOnly first, DateOnly end, BucketSize size = BucketSize.Month) => + new(first, end, AnalysisClock.At(AnalysisClock.Berlin, first.Year, first.Month, first.Day), + AnalysisClock.At(AnalysisClock.Berlin, end.Year, end.Month, end.Day), size); + + public static Tariff Tariff( + int id, + TariffScope scope, + int? scopeId, + TariffComponent component, + double value, + string unit, + DateOnly from, + DateOnly? to = null) => new() + { + Id = id, + ScopeType = scope, + ScopeId = scopeId, + Component = component, + Value = value, + Unit = unit, + ValidFrom = from, + ValidTo = to, + }; + + /// A unit price of an energy type. + public static Tariff TypePrice(int id, int energyTypeId, double value, string unit, DateOnly from, DateOnly? to = null) => + Tariff(id, TariffScope.EnergyType, energyTypeId, TariffComponent.UnitPrice, value, unit, from, to); + + /// A meter's own unit price. + public static Tariff MeterPrice(int id, int meterId, double value, string unit, DateOnly from, DateOnly? to = null) => + Tariff(id, TariffScope.Meter, meterId, TariffComponent.UnitPrice, value, unit, from, to); + + public static Tariff BasePrice(int id, TariffScope scope, int? scopeId, double value, string unit, DateOnly from, DateOnly? to = null) => + Tariff(id, scope, scopeId, TariffComponent.BasePrice, value, unit, from, to); + + public static Tariff FeedInPrice(int id, int energyTypeId, double value, string unit, DateOnly from) => + Tariff(id, TariffScope.EnergyType, energyTypeId, TariffComponent.FeedIn, value, unit, from); + + public static TariffBook Book(params Tariff[] tariffs) => TariffBook.Create(tariffs, "EUR"); + + /// Every part's quantity from a per-day amount: the sum over its days. + public static List Daily(IReadOnlyList buckets, Func perDay) => + [.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Known(p, DaysOf(p).Sum(perDay)))]; + + /// Every part's quantity from an amount per whole local month, prorated by days when a part is shorter. + public static List Monthly(IReadOnlyList buckets, Func perMonth) => + [.. CostCalculator.Parts(buckets).Select(p => perMonth(p.Month) is { } amount + ? CostQuantity.Known(p, amount * p.Days / DateTime.DaysInMonth(p.Month.Year, p.Month.Month)) + : CostQuantity.Unknown(p))]; + + /// Every part the same amount. + public static List Each(IReadOnlyList buckets, double amount) => + [.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Known(p, amount))]; + + public static CostLine Line( + int meterId, + int energyTypeId, + IReadOnlyList quantities, + string unit = "kWh", + BillLineKind kind = BillLineKind.UnitPrice, + ServicePeriod? service = null) => + new(meterId, energyTypeId, kind, unit, quantities, service); + + public static CostResult Price( + IReadOnlyList buckets, + TariffBook book, + IEnumerable lines, + DateOnly? today = null, + IEnumerable? standing = null, + IEnumerable? manual = null) => + CostCalculator.Calculate(new CostRequest( + buckets, + today ?? D(2039, 12, 31), + book, + [.. lines], + standing is null ? null : [.. standing], + manual is null ? null : [.. manual])); + + public static ManualCost Manual(int id, DateOnly start, double amount, int? categoryId = null, int? meterId = null, DateOnly? end = null, string currency = "EUR") => new() + { + Id = id, + CategoryId = categoryId, + MeterId = meterId, + PeriodStart = start, + PeriodEnd = end ?? start, + Amount = amount, + Currency = currency, + }; + + /// The cost of the bucket starting on . + public static CostAmount At(this IReadOnlyList cells, IReadOnlyList buckets, DateOnly first) + { + for (var i = 0; i < buckets.Count; i++) + { + if (buckets[i].FirstDay == first) + { + return cells[i]; + } + } + + throw new InvalidOperationException($"No bucket starts on {first:yyyy-MM-dd}."); + } + + private static IEnumerable DaysOf(CostPart part) => + Enumerable.Range(0, part.Days).Select(part.FirstDay.AddDays); + + private static IReadOnlyList Plan(ResolvedPeriod period, BucketSize size) + { + var plan = BucketPlanner.Plan(period, size, maxPoints: 10_000); + Assert.False(plan.Refused); + return plan.Buckets; + } +} diff --git a/tests/Core.Tests/Analysis/CoverageBuilderTests.cs b/tests/Core.Tests/Analysis/CoverageBuilderTests.cs new file mode 100644 index 0000000..25dfeb1 --- /dev/null +++ b/tests/Core.Tests/Analysis/CoverageBuilderTests.cs @@ -0,0 +1,410 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; +using static MeterVault.Core.Tests.Analysis.AnalysisTestTime; +using static MeterVault.Core.Tests.TestData; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// Coverage runs (D-13): which stretches of time a meter's data covers, at what resolution, and where +/// it is a known hole. What a bucket may claim — available, partial, unresolved, missing — is decided +/// from these runs, so the shapes the seeded instance really has are pinned here: monthly sheets, a +/// burner read once in twelve years, live snapshots, and the holes a register or sensor can leave. Runs are +/// stored uncapped and say where their last interval starts, so any reader can cut them at its own now (A-04). +/// +public sealed class CoverageBuilderTests +{ + private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); + + private static Reading Measured(DateTimeOffset time, double value) => + new() { MeterId = 1, Time = time, Value = value, Quality = ReadingQuality.Measured }; + + private static TimeZoneInfo Zone(string id) => id == "UTC" ? TimeZoneInfo.Utc : TimeZoneInfo.FindSystemTimeZoneById(id); + + private static CoverageRun Run( + DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided, DateTimeOffset last, CoverageGapReason gap = CoverageGapReason.None) => + new(from.ToUniversalTime(), to.ToUniversalTime(), resolution, divided, gap, last.ToUniversalTime()); + + private IReadOnlyList Coverage(MeterMode mode, TimeZoneInfo zone, IReadOnlyList readings, IReadOnlyList? events = null) => + CoverageBuilder.Build( + _engine.Normalize(new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "kWh" }, + Readings = readings, + Events = events ?? [], + TimeZone = zone, + }), + zone); + + [Theory] + [InlineData("UTC")] + [InlineData("Europe/Berlin")] + [InlineData("America/New_York")] + public void A_monthly_sheet_is_one_month_run_divided_at_months(string zoneId) + { + var zone = Zone(zoneId); + var readings = Enumerable.Range(0, 12).Select(i => Reading(1, Month(2022, 9).AddMonths(i), 100 * i)).ToList(); + + var run = Assert.Single(Coverage(MeterMode.CumulativeCounter, zone, readings)); + + Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2022, 9, 1), zone), run.From); + Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2023, 9, 1), zone), run.To); + Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2023, 8, 1), zone), run.LastIntervalStart); + Assert.Equal(ResolutionClass.Month, run.Resolution); + Assert.True(run.DividedAtMonths); + Assert.False(run.IsGap); + } + + [Fact] + public void A_twelve_year_burner_interval_is_its_own_coarse_run_followed_by_a_month_run() + { + // The seeded burner shape: 0 h on 18.10.2010, then monthly rows from "Oktober 2022" on. The twelve + // years of hours cannot be placed in any month; the months after can. + var runs = Coverage(MeterMode.RuntimeCounter, Berlin, + [ + DayReading(1, Utc(2010, 10, 18), 0), + Reading(1, Month(2022, 10), 7758), + Reading(1, Month(2022, 11), 7785), + Reading(1, Month(2022, 12), 7952), + Reading(1, Month(2023, 1), 8127), + ]); + + Assert.Equal( + [ + Run(Utc(2010, 10, 18), InBerlin(2022, 11, 1), ResolutionClass.Coarse, divided: false, last: Utc(2010, 10, 18)), + Run(InBerlin(2022, 11, 1), InBerlin(2023, 2, 1), ResolutionClass.Month, divided: true, last: InBerlin(2023, 1, 1)), + ], + runs); + } + + [Fact] + public void Consecutive_intervals_longer_than_a_month_are_each_their_own_run() + { + var runs = Coverage(MeterMode.RuntimeCounter, Berlin, + [DayReading(1, Utc(2022, 1, 10), 0), DayReading(1, Utc(2022, 3, 10), 100), DayReading(1, Utc(2022, 5, 10), 180)]); + + Assert.Equal( + [ + Run(Utc(2022, 1, 10), Utc(2022, 3, 10), ResolutionClass.Coarse, divided: false, last: Utc(2022, 1, 10)), + Run(Utc(2022, 3, 10), Utc(2022, 5, 10), ResolutionClass.Coarse, divided: false, last: Utc(2022, 3, 10)), + ], + runs); + } + + [Fact] + public void A_divided_interval_longer_than_a_month_is_month_class_so_month_charts_stay_monthly() + { + // m7 #3, A-03: two six-week gaps between hand readings, each divided at the month starts it crosses. + // Month buckets can place every share, so the data is month-resolution — not coarse, which would make + // auto bucketing jump to years — and no share is finer than the six weeks it was estimated from. + var runs = Coverage(MeterMode.CumulativeCounter, Berlin, + [ + Measured(InBerlin(2026, 8, 1, 9), 700), + Measured(InBerlin(2026, 9, 16, 18), 746), + Measured(InBerlin(2026, 11, 2, 12), 800), + ]); + + var run = Assert.Single(runs); + Assert.Equal(Run(InBerlin(2026, 8, 1, 9), InBerlin(2026, 11, 2, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 11, 1)), run); + Assert.Equal(BucketSize.Month, BucketPlanner.MinimumSizeFor(run.Resolution)); + } + + [Fact] + public void Two_divided_intervals_meeting_at_a_month_start_are_classified_each_by_its_own_length() + { + // m7 #3: read on 31 January 12:00, at 00:00 on 1 March, and on 2 April 12:00. Both intervals are + // divided and their shares meet at 1 March like the shares of one interval do; told apart by their + // source intervals they are 29.5 and 32.5 days — month-class, never one 61-day coarse interval. + var rows = _engine.Normalize(new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, + Readings = [Measured(InBerlin(2026, 1, 31, 12), 100), Measured(InBerlin(2026, 3, 1), 130), Measured(InBerlin(2026, 4, 2, 12), 170)], + TimeZone = Berlin, + }); + + var run = Assert.Single(CoverageBuilder.Build(rows, Berlin)); + + Assert.Equal(ResolutionClass.Month, run.Resolution); + Assert.True(run.DividedAtMonths); + Assert.Equal( + [(InBerlin(2026, 1, 31, 12), InBerlin(2026, 3, 1)), (InBerlin(2026, 3, 1), InBerlin(2026, 4, 2, 12))], + rows.Where(r => r.Divided).Select(r => (r.SourceStart!.Value, r.SourceEnd!.Value)).Distinct()); + } + + [Fact] + public void Month_long_readings_on_the_twentieth_are_divided_and_merge_into_one_month_run() + { + var runs = Coverage(MeterMode.CumulativeCounter, Berlin, + [ + Measured(InBerlin(2026, 6, 20, 12), 100), + Measured(InBerlin(2026, 7, 20, 12), 130), + Measured(InBerlin(2026, 8, 20, 12), 170), + Measured(InBerlin(2026, 9, 20, 12), 200), + ]); + + Assert.Equal([Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 9, 20, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 9, 1))], runs); + } + + [Fact] + public void A_register_that_stands_still_across_a_month_boundary_stays_in_the_month_aligned_run() + { + // m7 #2: the wallbox read on the 20th did not move from 20 June to 20 July. Zero is zero in both months. + var runs = Coverage(MeterMode.CumulativeCounter, Berlin, + [ + Measured(InBerlin(2026, 5, 20, 12), 100), + Measured(InBerlin(2026, 6, 20, 12), 130), + Measured(InBerlin(2026, 7, 20, 12), 130), + Measured(InBerlin(2026, 8, 20, 12), 160), + ]); + + var run = Assert.Single(runs); + Assert.True(run.DividedAtMonths); + Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CoverageTestData.Month(2026, 6), runs, Berlin, false).Status); + Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CoverageTestData.Month(2026, 7), runs, Berlin, false).Status); + } + + [Fact] + public void Burner_hours_that_stand_still_across_a_month_boundary_are_month_aligned_but_moving_hours_are_not() + { + var runs = Coverage(MeterMode.RuntimeCounter, Berlin, + [ + Measured(InBerlin(2026, 5, 20, 12), 100), + Measured(InBerlin(2026, 6, 20, 12), 100), + Measured(InBerlin(2026, 7, 20, 12), 140), + ]); + + Assert.Equal( + [ + Run(InBerlin(2026, 5, 20, 12), InBerlin(2026, 6, 20, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 5, 20, 12)), + Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 7, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 6, 20, 12)), + ], + runs); + } + + [Fact] + public void Undivided_monthly_intervals_that_straddle_month_starts_are_each_their_own_run() + { + // Burner hours read on the 20th: every interval straddles a month start undivided, so its own two + // readings are the only places it can be cut — a run keeps only its ends, so each interval is one. + var runs = Coverage(MeterMode.RuntimeCounter, Berlin, + [ + Measured(InBerlin(2026, 6, 20, 12), 100), + Measured(InBerlin(2026, 7, 20, 12), 130), + Measured(InBerlin(2026, 8, 20, 12), 170), + ]); + + Assert.Equal( + [ + Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 7, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 6, 20, 12)), + Run(InBerlin(2026, 7, 20, 12), InBerlin(2026, 8, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 7, 20, 12)), + ], + runs); + } + + [Fact] + public void Undivided_weekly_hours_that_straddle_a_month_start_are_not_divided_at_months() + { + // Burner hours read every Monday. The week across 1 September is one undivided interval, so a + // month bucket cannot split it; a register read on the same days divides that week and stays whole. + Reading[] mondays = + [ + Measured(InBerlin(2026, 8, 17, 10), 100), + Measured(InBerlin(2026, 8, 24, 10), 110), + Measured(InBerlin(2026, 8, 31, 10), 125), + Measured(InBerlin(2026, 9, 7, 10), 131), + Measured(InBerlin(2026, 9, 14, 10), 140), + ]; + + var hours = Coverage(MeterMode.RuntimeCounter, Berlin, mondays); + var register = Coverage(MeterMode.CumulativeCounter, Berlin, mondays); + + Assert.Equal( + [ + Run(InBerlin(2026, 8, 17, 10), InBerlin(2026, 8, 31, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 8, 24, 10)), + Run(InBerlin(2026, 8, 31, 10), InBerlin(2026, 9, 7, 10), ResolutionClass.Week, divided: false, last: InBerlin(2026, 8, 31, 10)), + Run(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 14, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 9, 7, 10)), + ], + hours); + Assert.Equal([Run(InBerlin(2026, 8, 17, 10), InBerlin(2026, 9, 14, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 9, 7, 10))], register); + } + + [Fact] + public void Midnight_snapshots_are_one_day_run() + { + var snapshots = Enumerable.Range(0, 10).Select(i => Measured(InBerlin(2026, 8, 25).AddDays(i), 100 + i)).ToList(); + + var run = Assert.Single(Coverage(MeterMode.CumulativeCounter, Berlin, snapshots)); + + Assert.Equal(Run(InBerlin(2026, 8, 25), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 2)), run); + } + + [Fact] + public void An_unexplained_decrease_is_a_gap_between_covered_runs() + { + var runs = Coverage(MeterMode.CumulativeCounter, Berlin, + [ + Measured(InBerlin(2026, 9, 1), 10), + Measured(InBerlin(2026, 9, 2), 11), + Measured(InBerlin(2026, 9, 3), 12), + Measured(InBerlin(2026, 9, 4), 5), + Measured(InBerlin(2026, 9, 5), 6), + Measured(InBerlin(2026, 9, 6), 7), + ]); + + Assert.Equal( + [ + Run(InBerlin(2026, 9, 1), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 2)), + Run(InBerlin(2026, 9, 3), InBerlin(2026, 9, 4), ResolutionClass.Day, divided: false, last: InBerlin(2026, 9, 3), CoverageGapReason.UnexplainedDecrease), + Run(InBerlin(2026, 9, 4), InBerlin(2026, 9, 6), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 5)), + ], + runs); + Assert.True(runs[1].IsGap); + } + + [Fact] + public void Consecutive_decreases_are_one_gap_run() + { + var runs = Coverage(MeterMode.CumulativeCounter, Berlin, + [Measured(InBerlin(2026, 9, 1), 10), Measured(InBerlin(2026, 9, 2), 9), Measured(InBerlin(2026, 9, 3), 8)]); + + Assert.Equal( + [Run(InBerlin(2026, 9, 1), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: false, last: InBerlin(2026, 9, 2), CoverageGapReason.UnexplainedDecrease)], + runs); + } + + [Fact] + public void A_reset_that_does_not_say_where_the_register_stopped_is_a_gap_run() + { + var runs = CoverageBuilder.Build( + _engine.Normalize(new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, + Readings = [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 150), Reading(1, Month(2023, 3), 30), Reading(1, Month(2023, 4), 80)], + Events = [Reset(1, Month(2023, 3), newValue: 0)], + }), + TimeZoneInfo.Utc); + + Assert.Equal( + [ + Run(Month(2023, 1), Month(2023, 3), ResolutionClass.Month, divided: true, last: Month(2023, 2)), + Run(Month(2023, 3), Month(2023, 4), ResolutionClass.Month, divided: false, last: Month(2023, 3), CoverageGapReason.ResetWithoutPrevious), + Run(Month(2023, 4), Month(2023, 5), ResolutionClass.Month, divided: true, last: Month(2023, 4)), + ], + runs); + } + + [Fact] + public void A_sensor_silence_is_a_gap_run_between_hourly_runs() + { + var samples = Enumerable.Range(0, 13).Select(i => Measured(Utc(2024, 6, 1, 10).AddMinutes(5 * i), 2)) + .Append(Measured(Utc(2024, 6, 1, 14), 2)) + .Append(Measured(Utc(2024, 6, 1, 14, 5), 2)) + .ToList(); + + var runs = Coverage(MeterMode.InstantRate, TimeZoneInfo.Utc, samples); + + Assert.Equal( + [ + Run(Utc(2024, 6, 1, 10), Utc(2024, 6, 1, 11), ResolutionClass.Hour, divided: true, last: Utc(2024, 6, 1, 10, 55)), + Run(Utc(2024, 6, 1, 11), Utc(2024, 6, 1, 14), ResolutionClass.Day, divided: false, last: Utc(2024, 6, 1, 11), CoverageGapReason.SampleGap), + Run(Utc(2024, 6, 1, 14), Utc(2024, 6, 1, 14, 5), ResolutionClass.Hour, divided: true, last: Utc(2024, 6, 1, 14)), + ], + runs); + } + + [Fact] + public void Stored_runs_describe_rows_recorded_after_now_until_a_reader_caps_them() + { + // m7 #4, A-04: "September 2026" imported on the 19th. The stored run still covers September — the + // rebuild's "now" must not be frozen into it — and capping at the reader's now gives the month up. + var now = InBerlin(2026, 9, 19, 12); + var runs = Coverage(MeterMode.CumulativeCounter, Berlin, + [Reading(1, Month(2026, 7), 100), Reading(1, Month(2026, 8), 130), Reading(1, Month(2026, 9), 170)]); + + Assert.Equal([Run(InBerlin(2026, 7, 1), InBerlin(2026, 10, 1), ResolutionClass.Month, divided: true, last: InBerlin(2026, 9, 1))], runs); + Assert.Equal( + [new CoverageRun(InBerlin(2026, 7, 1).ToUniversalTime(), InBerlin(2026, 9, 1).ToUniversalTime(), ResolutionClass.Month, DividedAtMonths: true)], + CoverageRuns.CapAt(runs, now, Berlin)); + } + + [Fact] + public void A_future_reading_costs_only_the_share_that_closes_after_now() + { + // m2 #5: read on the 15th, the 15 September reading stamped ahead (now: 10 September). The August + // share of its interval closed on 1 September and stays covered. + var runs = Coverage(MeterMode.CumulativeCounter, Berlin, + [ + Measured(InBerlin(2026, 7, 15, 8), 100), + Measured(InBerlin(2026, 8, 15, 8), 130), + Measured(InBerlin(2026, 9, 15, 8), 170), + ]); + + var capped = Assert.Single(CoverageRuns.CapAt(runs, InBerlin(2026, 9, 10, 12), Berlin)); + + Assert.Equal(InBerlin(2026, 9, 1), capped.To); + } + + [Fact] + public void A_tank_that_was_not_drawn_from_across_a_month_boundary_is_month_aligned() + { + var rows = _engine.Normalize(new NormalizationContext + { + Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" }, + Events = + [ + new MeterEvent { MeterId = 30, Time = InBerlin(2026, 6, 20, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" }, + new MeterEvent { MeterId = 30, Time = InBerlin(2026, 7, 20, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" }, + new MeterEvent { MeterId = 30, Time = InBerlin(2026, 8, 20, 10), EventType = MeterEventType.TankLevel, Amount = 2800, Unit = "L" }, + ], + TimeZone = Berlin, + }); + + var runs = CoverageBuilder.Build(rows, Berlin); + + Assert.Equal([true, false], runs.Select(r => r.DividedAtMonths)); + } + + [Fact] + public void An_opening_balance_and_rows_without_an_interval_cover_nothing() + { + Assert.Empty(Coverage(MeterMode.CumulativeCounter, Berlin, [Measured(InBerlin(2026, 9, 2, 8), 300)])); + Assert.Empty(CoverageBuilder.Build([new Consumption { MeterId = 1, Time = Utc(2026, 9, 1), Amount = 5 }], Berlin)); + } + + [Fact] + public void Overlapping_rows_never_produce_overlapping_runs() + { + // A nine-day and a seven-day interval that overlap: the earlier one keeps the overlap, the later one + // keeps its own resolution. + Consumption Row(DateTimeOffset from, DateTimeOffset to) => + new() { MeterId = 1, Time = to, Amount = 1, IntervalStart = from, IntervalEnd = to }; + + var runs = CoverageBuilder.Build( + [Row(Utc(2026, 1, 5), Utc(2026, 1, 12)), Row(Utc(2026, 1, 1), Utc(2026, 1, 10)), Row(Utc(2026, 1, 2), Utc(2026, 1, 4))], + TimeZoneInfo.Utc); + + Assert.Equal( + [ + Run(Utc(2026, 1, 1), Utc(2026, 1, 10), ResolutionClass.Month, divided: true, last: Utc(2026, 1, 1)), + Run(Utc(2026, 1, 10), Utc(2026, 1, 12), ResolutionClass.Week, divided: true, last: Utc(2026, 1, 10)), + ], + runs); + } + + [Fact] + public void Runs_are_in_utc_whatever_offset_the_rows_carry() + { + var from = new DateTimeOffset(2026, 9, 1, 8, 0, 0, TimeSpan.FromHours(2)); + var to = new DateTimeOffset(2026, 9, 1, 9, 0, 0, TimeSpan.FromHours(2)); + + var run = Assert.Single(CoverageBuilder.Build( + [new Consumption { MeterId = 1, Time = to, Amount = 1, IntervalStart = from, IntervalEnd = to }], Berlin)); + + Assert.Equal(TimeSpan.Zero, run.From.Offset); + Assert.Equal(TimeSpan.Zero, run.To.Offset); + Assert.Equal(TimeSpan.Zero, run.LastIntervalStart!.Value.Offset); + Assert.Equal(from, run.From); + } +} diff --git a/tests/Core.Tests/Analysis/CoverageEvaluatorTests.cs b/tests/Core.Tests/Analysis/CoverageEvaluatorTests.cs new file mode 100644 index 0000000..88e4a0c --- /dev/null +++ b/tests/Core.Tests/Analysis/CoverageEvaluatorTests.cs @@ -0,0 +1,809 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; +using static MeterVault.Core.Tests.Analysis.CoverageTestData; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// A bucket's status comes from coverage runs, not from sums (D-14). What these pin down: monthly data stays +/// monthly — it resolves months and years and nothing finer; an interval divided at month edges resolves +/// months but not weeks; a long undivided interval leaves every bucket whose edge it crosses unresolved, +/// within the 5 % edge tolerance; outages make exactly the buckets they touch partial or missing; an +/// opening balance, which the caller reports from the rollup flag, keeps its bucket partial (A-01); and a +/// bucket that ends at now is judged on what has happened by then (A-04). +/// +public sealed class CoverageEvaluatorTests +{ + private static readonly IReadOnlyList NoGaps = []; + + private static BucketCoverage Evaluate(AnalysisBucket bucket, params CoverageRun[] runs) => + CoverageEvaluator.Evaluate(bucket, runs, Berlin, openingBalanceInBucket: false); + + // ---- Seeded water meter: imported monthly table rows --------------------------------------------- + + [Fact] + public void Monthly_label_data_resolves_its_months_and_years() + { + var water = MonthLabels(2022, 1, 2023, 1); + + var december = Evaluate(Month(2022, 12), water); + var year = Evaluate(Year(2022), water); + + Assert.Equal(BucketStatus.Available, december.Status); + Assert.Equal(ValueIssue.None, december.Issue); + Assert.Equal(ResolutionClass.Month, december.Resolution); + Assert.Equal(BucketStatus.Available, year.Status); + } + + [Fact] + public void Monthly_label_data_is_unresolved_for_days_and_weeks_although_it_covers_them() + { + var water = MonthLabels(2022, 1, 2023, 1); + + foreach (var bucket in new[] { Day(2022, 12, 15), Week(2022, 12, 14), Week(2022, 6, 1), Day(2022, 12, 31), Day(2022, 1, 1) }) + { + var coverage = Evaluate(bucket, water); + + Assert.Equal(BucketStatus.Unresolved, coverage.Status); + Assert.Equal(ValueIssue.CoarseResolution, coverage.Issue); + Assert.Equal(1d, coverage.CoveredFraction, 9); + } + } + + // ---- A counter read on 1 August and 16 September ------------------------------------------------- + + public static TheoryData DividedIntervalShapes => ["one coarse divided run", "month shares (D-10 segment bounds)"]; + + private static CoverageRun[] CounterReadAugustToSeptember(string shape) + { + // As one run the 46-day interval is coarse. As D-10's month shares (1 Aug 09:00 - 1 Sep and + // 1 Sep - 16 Sep 18:00) both shares are month-class and merge into one month-class run. Either way + // the normalizer divided it at the month edge; daily readings surround it. + var interval = shape == "one coarse divided run" + ? Run(At(2026, 8, 1, 9), At(2026, 9, 16, 18), ResolutionClass.Coarse, divided: true) + : Run(At(2026, 8, 1, 9), At(2026, 9, 16, 18), ResolutionClass.Month, divided: true); + + return + [ + Run(At(2026, 7, 1), At(2026, 8, 1, 9), ResolutionClass.Day), + interval, + Run(At(2026, 9, 16, 18), At(2026, 10, 1), ResolutionClass.Day), + ]; + } + + [Theory] + [MemberData(nameof(DividedIntervalShapes))] + public void A_46_day_interval_divided_at_the_month_edge_resolves_both_months(string shape) + { + var runs = CounterReadAugustToSeptember(shape); + + Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 8), runs).Status); + Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 9), runs).Status); + } + + [Theory] + [MemberData(nameof(DividedIntervalShapes))] + public void A_46_day_interval_divided_at_the_month_edge_still_cannot_say_which_week_it_was_used_in(string shape) + { + var runs = CounterReadAugustToSeptember(shape); + + // Mid-August, the week across the month edge (Mon 31 Aug), and the week the closing reading lies in. + foreach (var bucket in new[] { Week(2026, 8, 12), Week(2026, 8, 31), Week(2026, 9, 16), Day(2026, 8, 20) }) + { + Assert.Equal(BucketStatus.Unresolved, Evaluate(bucket, runs).Status); + } + + // The weeks around it are resolved by the daily readings on either side. + Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 7, 15), runs).Status); + Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 9, 23), runs).Status); + } + + // ---- Seeded burner runtime: one 12-year undivided interval --------------------------------------- + + [Fact] + public void A_twelve_year_undivided_runtime_interval_leaves_its_months_and_years_unresolved() + { + var brenner = Run(At(2013, 10, 1), At(2025, 10, 1), ResolutionClass.Coarse); + var afterwards = Run(At(2025, 10, 1), At(2026, 6, 1), ResolutionClass.Month, divided: true); + + Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2022), brenner, afterwards).Status); + Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2022, 3), brenner, afterwards).Status); + + // The year it closes in would receive twelve years of runtime. + Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2025), brenner, afterwards).Status); + Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 2), brenner, afterwards).Status); + } + + // ---- Seeded oil tank: dipstick levels weeks apart, never divided --------------------------------- + + private static readonly CoverageRun[] Tank = + [ + Run(At(2025, 9, 20), At(2025, 10, 14), ResolutionClass.Month), + Run(At(2025, 10, 14), At(2025, 11, 28), ResolutionClass.Coarse), + Run(At(2025, 11, 28), At(2025, 12, 20), ResolutionClass.Month), + ]; + + [Fact] + public void A_45_day_undivided_tank_interval_leaves_both_months_it_spans_unresolved() + { + var october = Evaluate(Month(2025, 10), Tank); + var november = Evaluate(Month(2025, 11), Tank); + + Assert.Equal(BucketStatus.Unresolved, october.Status); + Assert.Equal(ValueIssue.CoarseResolution, october.Issue); + Assert.Equal(ResolutionClass.Coarse, october.Resolution); + Assert.Equal(BucketStatus.Unresolved, november.Status); + } + + [Fact] + public void A_45_day_tank_interval_inside_one_year_does_not_make_the_year_unresolved() + { + // D-14: only an interval crossing a bucket edge misbooks. This one starts and ends in 2025, so the + // year total is right — merely partial, because the tank was only read from 20 September. + var year = Evaluate(Year(2025), Tank); + + Assert.Equal(BucketStatus.Partial, year.Status); + Assert.Equal(ValueIssue.PartialCoverage, year.Issue); + Assert.Equal(At(2025, 9, 20), year.FirstCovered); + Assert.Equal(At(2025, 12, 20), year.LastCovered); + } + + [Fact] + public void A_month_class_tank_interval_crossing_a_month_edge_leaves_that_month_unresolved() + { + // D-14: the 20 Sep - 14 Oct dipstick interval is booked on 14 October, although 11 of its days lie in + // September. Being no longer than a month does not make an undivided interval land in the right month. + var september = Evaluate(Month(2025, 9), Tank); + + Assert.Equal(BucketStatus.Unresolved, september.Status); + Assert.Equal(ValueIssue.CoarseResolution, september.Issue); + Assert.Equal(At(2025, 9, 20), september.FirstCovered); + } + + [Fact] + public void Undivided_month_class_intervals_read_mid_month_leave_their_months_unresolved() + { + // Review F1: dipsticks (or burner hours) read on 15 Jan, 14 Feb and 16 Mar. February would receive the + // 15 Jan - 14 Feb draw, 17 of whose 30 days lie in January, so neither month can be told apart. + CoverageRun[] runs = + [ + Single(At(2026, 1, 15, 10), At(2026, 2, 14, 10), ResolutionClass.Month), + Single(At(2026, 2, 14, 10), At(2026, 3, 16, 10), ResolutionClass.Month), + ]; + + var february = CoverageEvaluator.Evaluate(Month(2026, 2), runs, Berlin, openingBalanceInBucket: false); + + Assert.Equal(BucketStatus.Unresolved, february.Status); + Assert.Equal(ValueIssue.CoarseResolution, february.Issue); + Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2026, 1), runs).Status); + + // The year holds both intervals whole: it is resolved, merely partial (read from 15 January only). + Assert.Equal(BucketStatus.Partial, Evaluate(Year(2026), runs).Status); + } + + [Fact] + public void Undivided_month_class_intervals_that_cross_a_month_edge_by_less_than_5_percent_stay_resolved() + { + // The seeded oil tank's dipsticks sit on month-end days: 30 Nov 01:00 - 31 Dec 01:00 crosses into + // December by 23 hours of 31 days (3 %), which D-14 tolerates. + CoverageRun[] runs = + [ + Single(At(2025, 11, 30, 1), At(2025, 12, 31, 1), ResolutionClass.Month), + Single(At(2025, 12, 31, 1), At(2026, 1, 31, 1), ResolutionClass.Month), + Single(At(2026, 1, 31, 1), At(2026, 2, 28, 1), ResolutionClass.Month), + ]; + + Assert.Equal(BucketStatus.Available, Evaluate(Month(2025, 12), runs).Status); + Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 1), runs).Status); + } + + [Fact] + public void Day_class_intervals_across_a_month_edge_still_resolve_months_on_the_class_rule() + { + // Daily readings at 06:00 cross each month edge by six hours, far inside the tolerance. + var run = Single(At(2026, 1, 31, 6), At(2026, 2, 1, 6), ResolutionClass.Day); + + Assert.True(CoverageEvaluator.Resolves(run, Month(2026, 1), Berlin)); + Assert.True(CoverageEvaluator.Resolves(run, Month(2026, 2), Berlin)); + } + + + [Fact] + public void An_undivided_interval_crossing_new_year_by_less_than_5_percent_of_a_year_keeps_both_years_resolved() + { + CoverageRun[] runs = + [ + Run(At(2024, 1, 1), At(2024, 12, 20), ResolutionClass.Month), + Run(At(2024, 12, 20), At(2025, 2, 5), ResolutionClass.Coarse), + Run(At(2025, 2, 5), At(2026, 1, 1), ResolutionClass.Month), + ]; + + Assert.Equal(BucketStatus.Available, Evaluate(Year(2024), runs).Status); + Assert.Equal(BucketStatus.Available, Evaluate(Year(2025), runs).Status); + Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2024, 12), runs).Status); + Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2025, 1), runs).Status); + } + + [Fact] + public void An_undivided_interval_crossing_new_year_by_more_than_5_percent_of_a_year_leaves_both_years_unresolved() + { + // 22 days on each side of a 365-day bucket is 6 %. + CoverageRun[] runs = + [ + Run(At(2024, 1, 1), At(2024, 12, 10), ResolutionClass.Month), + Run(At(2024, 12, 10), At(2025, 1, 23), ResolutionClass.Coarse), + Run(At(2025, 1, 23), At(2026, 1, 1), ResolutionClass.Month), + ]; + + Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2024), runs).Status); + Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2025), runs).Status); + } + + [Fact] + public void Unresolved_wins_over_partial_because_a_value_too_coarse_to_divide_is_not_a_partial_total() + { + var coverage = Evaluate(Month(2026, 3), Run(At(2026, 3, 10), At(2026, 4, 20), ResolutionClass.Coarse)); + + Assert.Equal(BucketStatus.Unresolved, coverage.Status); + Assert.True(coverage.CoveredFraction < 1); + } + + // ---- Hourly samples with a three-day outage ------------------------------------------------------ + + private static readonly CoverageRun[] HourlyWithOutage = + [ + Run(At(2026, 1, 1), At(2026, 3, 10, 14), ResolutionClass.Hour), + Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap), + Run(At(2026, 3, 13, 9), At(2026, 5, 1), ResolutionClass.Hour), + ]; + + [Fact] + public void An_outage_leaves_exactly_the_days_it_touches_partial_or_missing() + { + var notAvailable = new List<(DateOnly Day, BucketStatus Status)>(); + for (var day = new DateOnly(2026, 3, 1); day < new DateOnly(2026, 5, 1); day = day.AddDays(1)) + { + var coverage = Evaluate(Day(day.Year, day.Month, day.Day), HourlyWithOutage); + if (coverage.Status != BucketStatus.Available) + { + notAvailable.Add((day, coverage.Status)); + Assert.Equal(ValueIssue.SampleGap, coverage.Issue); + } + } + + Assert.Equal( + [ + (new DateOnly(2026, 3, 10), BucketStatus.Partial), + (new DateOnly(2026, 3, 11), BucketStatus.Missing), + (new DateOnly(2026, 3, 12), BucketStatus.Missing), + (new DateOnly(2026, 3, 13), BucketStatus.Partial), + ], + notAvailable); + } + + [Fact] + public void An_outage_makes_its_week_and_month_partial_and_leaves_their_neighbours_available() + { + var outageWeek = Evaluate(Week(2026, 3, 11), HourlyWithOutage); + + Assert.Equal(BucketStatus.Partial, outageWeek.Status); + Assert.Equal(ValueIssue.SampleGap, outageWeek.Issue); + Assert.Equal([CoverageGapReason.SampleGap], outageWeek.Gaps); + Assert.Equal(TimeSpan.FromDays(7) - (At(2026, 3, 13, 9) - At(2026, 3, 10, 14)), outageWeek.Covered); + + Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 3, 4), HourlyWithOutage).Status); + Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 3, 18), HourlyWithOutage).Status); + Assert.Equal(BucketStatus.Partial, Evaluate(Month(2026, 3), HourlyWithOutage).Status); + Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 2), HourlyWithOutage).Status); + Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 4), HourlyWithOutage).Status); + } + + [Fact] + public void The_first_outage_day_reports_what_is_covered_and_until_when() + { + var coverage = Evaluate(Day(2026, 3, 10), HourlyWithOutage); + + Assert.Equal(TimeSpan.FromHours(14), coverage.Covered); + Assert.Equal(14d / 24, coverage.CoveredFraction, 9); + Assert.Equal(At(2026, 3, 10), coverage.FirstCovered); + Assert.Equal(At(2026, 3, 10, 14), coverage.LastCovered); + Assert.Equal(ResolutionClass.Hour, coverage.Resolution); + } + + [Fact] + public void Hourly_data_resolves_the_23_and_25_hour_DST_days() + { + var hourly = Run(At(2026, 3, 1), At(2026, 11, 1), ResolutionClass.Hour); + + Assert.Equal(BucketStatus.Available, Evaluate(Day(2026, 3, 29), hourly).Status); + Assert.Equal(BucketStatus.Available, Evaluate(Day(2026, 10, 25), hourly).Status); + } + + // ---- Edges and tolerances -------------------------------------------------------------------------- + + [Fact] + public void A_bucket_nothing_covers_is_missing_not_zero() + { + var coverage = Evaluate(Month(2026, 3)); + + Assert.Equal(BucketStatus.Missing, coverage.Status); + Assert.Equal(ValueIssue.NoCoverage, coverage.Issue); + Assert.Null(coverage.FirstCovered); + Assert.Null(coverage.Resolution); + Assert.Equal(0d, coverage.CoveredFraction); + } + + [Fact] + public void Runs_that_only_touch_the_bucket_edges_do_not_cover_it() + { + var march = Month(2026, 3); + var coverage = Evaluate( + march, + Run(At(2026, 2, 1), march.From, ResolutionClass.Hour), + Run(march.To, At(2026, 5, 1), ResolutionClass.Hour)); + + Assert.Equal(BucketStatus.Missing, coverage.Status); + } + + [Fact] + public void Coverage_short_by_less_than_a_minute_is_complete_and_by_two_minutes_is_partial() + { + var day = Day(2026, 6, 10); + + var jitter = Evaluate(day, Run(day.From.AddSeconds(59), day.To, ResolutionClass.Hour)); + var shortfall = Evaluate(day, Run(day.From.AddMinutes(2), day.To, ResolutionClass.Hour)); + + Assert.Equal(BucketStatus.Available, jitter.Status); + Assert.Equal(BucketStatus.Partial, shortfall.Status); + Assert.Equal(ValueIssue.PartialCoverage, shortfall.Issue); + } + + [Fact] + public void Overlapping_runs_are_unioned_rather_than_counted_twice() + { + var day = Day(2026, 6, 10); + var coverage = Evaluate( + day, + Run(day.From, day.From.AddHours(16), ResolutionClass.Hour), + Run(day.From.AddHours(8), day.From.AddHours(20), ResolutionClass.Hour)); + + Assert.Equal(TimeSpan.FromHours(20), coverage.Covered); + Assert.Equal(BucketStatus.Partial, coverage.Status); + } + + [Fact] + public void An_empty_clipped_bucket_is_missing() + { + var instant = At(2026, 9, 1); + var empty = new AnalysisBucket(new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 1), instant, instant, BucketSize.Day); + + Assert.Equal(BucketStatus.Missing, Evaluate(empty, Run(At(2026, 8, 1), At(2026, 10, 1), ResolutionClass.Hour)).Status); + } + + [Fact] + public void A_bucket_that_ends_before_it_starts_is_a_caller_error() + { + var reversed = new AnalysisBucket(new DateOnly(2026, 9, 2), new DateOnly(2026, 9, 1), At(2026, 9, 2), At(2026, 9, 1), BucketSize.Day); + + Assert.Throws(() => Evaluate(reversed)); + } + + // ---- Opening balances and register discontinuities ------------------------------------------------ + + [Fact] + public void An_opening_balance_keeps_its_bucket_partial_even_when_readings_cover_the_rest() + { + var firstReading = At(2026, 3, 10, 14); + CoverageRun[] runs = [Run(firstReading, At(2026, 5, 1), ResolutionClass.Hour, divided: true)]; + + var day = CoverageEvaluator.Evaluate(Day(2026, 3, 10), runs, Berlin, openingBalanceInBucket: true); + var month = CoverageEvaluator.Evaluate(Month(2026, 3), runs, Berlin, openingBalanceInBucket: true); + var nextDay = CoverageEvaluator.Evaluate(Day(2026, 3, 11), runs, Berlin, openingBalanceInBucket: false); + + Assert.Equal(BucketStatus.Partial, day.Status); + Assert.Equal(ValueIssue.OpeningBalance, day.Issue); + Assert.True(day.OpeningBalance); + Assert.Equal(ValueIssue.OpeningBalance, month.Issue); + Assert.Equal(BucketStatus.Available, nextDay.Status); + Assert.False(nextDay.OpeningBalance); + } + + [Fact] + public void A_bucket_holding_only_an_opening_balance_is_partial_not_missing() + { + var coverage = CoverageEvaluator.Evaluate(Day(2026, 3, 10), [], Berlin, openingBalanceInBucket: true); + + Assert.Equal(BucketStatus.Partial, coverage.Status); + Assert.Equal(ValueIssue.OpeningBalance, coverage.Issue); + Assert.Equal(TimeSpan.Zero, coverage.Covered); + } + + [Fact] + public void An_opening_balance_read_at_midnight_marks_the_day_its_row_is_booked_in_and_not_the_day_before() + { + // A-01: the flag follows the booked row. A first reading describes no time before its midnight, so + // its row stays on 10 March 00:00 (D-11 only moves rows that close an interval), and [From, To) + // files that stamp under 10 March, the day the rollup flags. + var midnight = At(2026, 3, 10); + var rows = NormalizationEngine.CreateDefault().Normalize(new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, + Readings = + [ + new Reading { MeterId = 1, Time = midnight, Value = 100, Quality = ReadingQuality.Measured }, + new Reading { MeterId = 1, Time = At(2026, 3, 10, 12), Value = 107, Quality = ReadingQuality.Measured }, + ], + TimeZone = Berlin, + }); + var runs = CoverageBuilder.Build(rows, Berlin); + var opening = Assert.Single(rows, r => r.OpeningBalance); + + bool BookedIn(AnalysisBucket bucket) => opening.Time >= bucket.From && opening.Time < bucket.To; + var dayBefore = CoverageEvaluator.Evaluate(Day(2026, 3, 9), runs, Berlin, BookedIn(Day(2026, 3, 9))); + var firstDay = CoverageEvaluator.Evaluate(Day(2026, 3, 10), runs, Berlin, BookedIn(Day(2026, 3, 10))); + + Assert.Equal(midnight, opening.Time); + Assert.False(dayBefore.OpeningBalance); + Assert.Equal(BucketStatus.Missing, dayBefore.Status); + Assert.True(firstDay.OpeningBalance); + Assert.Equal(BucketStatus.Partial, firstDay.Status); + Assert.Equal(ValueIssue.OpeningBalance, firstDay.Issue); + } + + [Theory] + [InlineData(CoverageGapReason.UnexplainedDecrease)] + [InlineData(CoverageGapReason.ResetWithoutPrevious)] + public void A_register_discontinuity_is_named_as_the_reason_a_bucket_is_partial_or_missing(CoverageGapReason reason) + { + CoverageRun[] runs = + [ + Run(At(2026, 1, 1), At(2026, 3, 5, 12), ResolutionClass.Day), + Gap(At(2026, 3, 5, 12), At(2026, 3, 9, 12), reason), + Run(At(2026, 3, 9, 12), At(2026, 5, 1), ResolutionClass.Day), + ]; + + var month = Evaluate(Month(2026, 3), runs); + var inside = Evaluate(Day(2026, 3, 7), runs); + + Assert.Equal(BucketStatus.Partial, month.Status); + Assert.Equal(ValueIssue.RegisterDiscontinuity, month.Issue); + Assert.Equal([reason], month.Gaps); + Assert.Equal(BucketStatus.Missing, inside.Status); + Assert.Equal(ValueIssue.RegisterDiscontinuity, inside.Issue); + } + + [Fact] + public void Gap_runs_never_count_as_coverage_and_never_make_a_bucket_unresolved() + { + var coverage = Evaluate(Month(2026, 3), Gap(At(2026, 1, 1), At(2026, 6, 1), CoverageGapReason.SampleGap)); + + Assert.Equal(BucketStatus.Missing, coverage.Status); + Assert.Equal(ValueIssue.SampleGap, coverage.Issue); + Assert.Equal(TimeSpan.Zero, coverage.Covered); + } + + [Fact] + public void Several_gap_reasons_are_listed_once_each_most_significant_first() + { + var coverage = Evaluate( + Month(2026, 3), + Run(At(2026, 3, 1), At(2026, 3, 5), ResolutionClass.Hour), + Gap(At(2026, 3, 5), At(2026, 3, 6), CoverageGapReason.SampleGap), + Run(At(2026, 3, 6), At(2026, 3, 10), ResolutionClass.Hour), + Gap(At(2026, 3, 10), At(2026, 3, 11), CoverageGapReason.UnexplainedDecrease), + Run(At(2026, 3, 11), At(2026, 3, 20), ResolutionClass.Hour), + Gap(At(2026, 3, 20), At(2026, 3, 21), CoverageGapReason.SampleGap), + Run(At(2026, 3, 21), At(2026, 4, 1), ResolutionClass.Hour)); + + Assert.Equal([CoverageGapReason.UnexplainedDecrease, CoverageGapReason.SampleGap], coverage.Gaps); + Assert.Equal(ValueIssue.RegisterDiscontinuity, coverage.Issue); + } + + [Fact] + public void Only_available_and_partial_buckets_carry_a_number() + { + var water = MonthLabels(2022, 1, 2023, 1); + + var available = Evaluate(Month(2022, 12), water).ToValue(14, Provenance.Imported); + var partial = Evaluate(Day(2026, 3, 10), HourlyWithOutage).ToValue(3.5, Provenance.Measured); + var unresolved = Evaluate(Day(2022, 12, 15), water).ToValue(14, Provenance.Imported); + var missing = Evaluate(Month(2030, 1), water).ToValue(0, Provenance.None); + + Assert.Equal(new BucketValue(14, BucketStatus.Available, Provenance.Imported), available); + Assert.Equal(new BucketValue(3.5, BucketStatus.Partial, Provenance.Measured, ValueIssue.SampleGap), partial); + Assert.Equal(new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution), unresolved); + Assert.Equal(BucketValue.Missing(), missing); + } + + // ---- Virtual meters: joint evaluation ----------------------------------------------------------------- + + private static readonly IReadOnlyList[] MonthlyAndHourlySources = + [ + [MonthLabels(2026, 1, 2026, 8)], + [Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)], + ]; + + [Fact] + public void A_virtual_bucket_is_as_coarse_as_its_coarsest_source() + { + Assert.Equal(BucketStatus.Available, CoverageEvaluator.EvaluateJoint(Month(2026, 4), MonthlyAndHourlySources, Berlin, false).Status); + + var day = CoverageEvaluator.EvaluateJoint(Day(2026, 4, 10), MonthlyAndHourlySources, Berlin, false); + Assert.Equal(BucketStatus.Unresolved, day.Status); + Assert.Equal(ResolutionClass.Month, day.Resolution); + } + + [Fact] + public void A_virtual_bucket_is_partial_where_its_sources_only_partly_overlap() + { + var march = CoverageEvaluator.EvaluateJoint(Month(2026, 3), MonthlyAndHourlySources, Berlin, false); + + Assert.Equal(BucketStatus.Partial, march.Status); + Assert.Equal(At(2026, 3, 15, 12), march.FirstCovered); + } + + [Fact] + public void A_missing_source_makes_the_virtual_bucket_missing() + { + var february = CoverageEvaluator.EvaluateJoint(Month(2026, 2), MonthlyAndHourlySources, Berlin, false); + var august = CoverageEvaluator.EvaluateJoint(Month(2026, 8), MonthlyAndHourlySources, Berlin, false); + + Assert.Equal(BucketStatus.Missing, february.Status); + Assert.Equal(ValueIssue.MissingSource, february.Issue); + Assert.Equal(BucketStatus.Missing, august.Status); + Assert.Equal(ValueIssue.MissingSource, august.Issue); + } + + [Fact] + public void Sources_covering_different_halves_of_a_bucket_cover_none_of_it_jointly() + { + IReadOnlyList[] sources = + [ + [Run(At(2026, 6, 1), At(2026, 6, 16), ResolutionClass.Hour)], + [Run(At(2026, 6, 16), At(2026, 7, 1), ResolutionClass.Hour)], + ]; + + var june = CoverageEvaluator.EvaluateJoint(Month(2026, 6), sources, Berlin, false); + + Assert.Equal(BucketStatus.Missing, june.Status); + Assert.Equal(ValueIssue.NoCoverage, june.Issue); + } + + [Fact] + public void A_source_whose_long_interval_is_clipped_by_the_other_sources_start_still_makes_the_bucket_unresolved() + { + // A books 1 January – 15 March in March. B only starts on 1 March, so the joint run is the clipped + // piece 1–15 March, which on its own looks like a harmless interval inside March. + IReadOnlyList[] sources = + [ + [Run(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Coarse), Run(At(2026, 3, 15), At(2026, 6, 1), ResolutionClass.Day)], + [Run(At(2026, 3, 1), At(2026, 6, 1), ResolutionClass.Hour)], + ]; + + var march = CoverageEvaluator.EvaluateJoint(Month(2026, 3), sources, Berlin, false); + + Assert.Equal(BucketStatus.Unresolved, march.Status); + Assert.Equal(BucketStatus.Available, CoverageEvaluator.EvaluateJoint(Month(2026, 4), sources, Berlin, false).Status); + } + + [Fact] + public void A_sources_opening_balance_reaches_the_virtual_bucket() + { + var firstReading = At(2026, 4, 10, 8); + IReadOnlyList[] sources = + [ + [Run(firstReading, At(2026, 6, 1), ResolutionClass.Hour)], + [Run(At(2026, 1, 1), At(2026, 6, 1), ResolutionClass.Hour)], + ]; + + var april = CoverageEvaluator.EvaluateJoint(Month(2026, 4), sources, Berlin, openingBalanceInBucket: true); + var may = CoverageEvaluator.EvaluateJoint(Month(2026, 5), sources, Berlin, openingBalanceInBucket: false); + + Assert.True(april.OpeningBalance); + Assert.Equal(BucketStatus.Partial, april.Status); + Assert.Equal(ValueIssue.OpeningBalance, april.Issue); + Assert.Equal(BucketStatus.Available, may.Status); + } + + [Fact] + public void A_virtual_meter_without_sources_covers_nothing() + { + Assert.Equal(BucketStatus.Missing, CoverageEvaluator.EvaluateJoint(Month(2026, 4), [], Berlin, false).Status); + } + + // ---- Zero-length runs, series, now ------------------------------------------------------------------ + + [Fact] + public void A_zero_length_run_inside_a_bucket_covers_nothing_and_leaves_it_missing() + { + var instant = At(2026, 3, 10, 14); + + var coverage = Evaluate(Day(2026, 3, 10), Run(instant, instant, ResolutionClass.Day)); + + Assert.Equal(BucketStatus.Missing, coverage.Status); + Assert.Equal(ValueIssue.NoCoverage, coverage.Issue); + Assert.Null(coverage.Resolution); + } + + [Fact] + public void A_series_is_evaluated_bucket_for_bucket_exactly_as_one_bucket_at_a_time() + { + // Unsorted, overlapping runs with holes and gaps, and buckets of every size in no particular order. + CoverageRun[] runs = + [ + Run(At(2026, 3, 13, 9), At(2026, 5, 1), ResolutionClass.Hour), + Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap), + MonthLabels(2025, 1, 2026, 1), + Single(At(2026, 1, 1), At(2026, 2, 20), ResolutionClass.Coarse), + Run(At(2026, 2, 20), At(2026, 3, 10, 14), ResolutionClass.Day, divided: true), + Run(At(2026, 4, 1), At(2026, 4, 3), ResolutionClass.Hour), + Run(At(2026, 6, 5), At(2026, 6, 5), ResolutionClass.Hour), + ]; + var buckets = new List { Year(2025), Month(2026, 3), Week(2026, 3, 11) }; + for (var day = new DateOnly(2026, 6, 10); day >= new DateOnly(2025, 12, 20); day = day.AddDays(-1)) + { + buckets.Add(Day(day.Year, day.Month, day.Day)); + } + + buckets.Add(Month(2026, 1)); + var flags = buckets.Select(b => b.FirstDay == new DateOnly(2026, 3, 13)).ToList(); + + var series = CoverageEvaluator.EvaluateSeries(buckets, runs, Berlin, flags); + + Assert.Equal(buckets.Count, series.Count); + for (var i = 0; i < buckets.Count; i++) + { + var one = CoverageEvaluator.Evaluate(buckets[i], runs, Berlin, flags[i]); + Assert.Equal(one with { Gaps = NoGaps }, series[i] with { Gaps = NoGaps }); + Assert.Equal(one.Gaps, series[i].Gaps); + } + } + + [Fact] + public void A_joint_series_is_evaluated_bucket_for_bucket_exactly_as_one_bucket_at_a_time() + { + IReadOnlyList[] sources = + [ + [MonthLabels(2026, 1, 2026, 8), Gap(At(2026, 8, 1), At(2026, 8, 3), CoverageGapReason.ResetWithoutPrevious)], + [Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)], + ]; + var buckets = Enumerable.Range(1, 9).Select(m => Month(2026, m)).Concat([Day(2026, 4, 10), Week(2026, 3, 16)]).Reverse().ToList(); + + var series = CoverageEvaluator.EvaluateJointSeries(buckets, sources, Berlin, openingBalanceInBucket: null); + + for (var i = 0; i < buckets.Count; i++) + { + var one = CoverageEvaluator.EvaluateJoint(buckets[i], sources, Berlin, openingBalanceInBucket: false); + Assert.Equal(one with { Gaps = NoGaps }, series[i] with { Gaps = NoGaps }); + Assert.Equal(one.Gaps, series[i].Gaps); + } + } + + [Fact] + public void A_series_needs_one_opening_balance_flag_per_bucket() + { + Assert.Throws(() => + CoverageEvaluator.EvaluateSeries([Day(2026, 3, 1), Day(2026, 3, 2)], [], Berlin, [true])); + } + + [Fact] + public void A_live_meters_day_and_month_to_date_are_complete_up_to_its_last_sample() + { + // m2 #9: five-minute samples, the last at 09:55, now 10:00. The five minutes since cannot have a row yet. + var now = At(2026, 9, 19, 10); + CoverageRun[] samples = [Run(At(2026, 8, 1), At(2026, 9, 19, 9, 55), ResolutionClass.Hour, divided: true)]; + + var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), samples, Berlin, false, now); + var monthToDate = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), samples, Berlin, false, now); + + Assert.Equal(BucketStatus.Available, today.Status); + Assert.Equal(BucketStatus.Available, monthToDate.Status); + Assert.Equal(At(2026, 9, 19, 9, 55), today.LastCovered); + } + + [Fact] + public void Daily_readings_leave_today_complete_until_the_next_reading_is_due() + { + // Read at 06:00 each day: today's bucket ends at now (10:00), four hours after the last reading. + var now = At(2026, 9, 19, 10); + CoverageRun[] daily = [Run(At(2026, 9, 1, 6), At(2026, 9, 19, 6), ResolutionClass.Day, divided: true)]; + + Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), daily, Berlin, false, now).Status); + } + + [Fact] + public void Coverage_that_stops_more_than_one_interval_before_now_is_partial() + { + var now = At(2026, 9, 19, 10); + CoverageRun[] hourly = [Run(At(2026, 9, 1), At(2026, 9, 19, 8, 30), ResolutionClass.Hour, divided: true)]; + + var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), hourly, Berlin, false, now); + + Assert.Equal(BucketStatus.Partial, today.Status); + Assert.Equal(ValueIssue.PartialCoverage, today.Issue); + } + + [Fact] + public void A_known_hole_before_now_is_a_shortfall_not_lag() + { + var now = At(2026, 9, 19, 10); + CoverageRun[] runs = + [ + Run(At(2026, 9, 1), At(2026, 9, 19, 9, 30), ResolutionClass.Hour, divided: true), + Gap(At(2026, 9, 19, 9, 30), At(2026, 9, 19, 9, 55), CoverageGapReason.SampleGap), + ]; + + var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), runs, Berlin, false, now); + + Assert.Equal(BucketStatus.Partial, today.Status); + Assert.Equal(ValueIssue.SampleGap, today.Issue); + } + + [Fact] + public void The_up_to_date_tolerance_applies_only_to_a_bucket_that_ends_at_now() + { + // The same shortfall in a finished day is a shortfall. + var now = At(2026, 9, 19, 10); + CoverageRun[] hourly = [Run(At(2026, 9, 1), At(2026, 9, 18, 23, 55), ResolutionClass.Hour, divided: true)]; + + Assert.Equal(BucketStatus.Partial, CoverageEvaluator.Evaluate(Day(2026, 9, 18), hourly, Berlin, false, now).Status); + Assert.Equal(BucketStatus.Partial, CoverageEvaluator.Evaluate(Day(2026, 9, 18), hourly, Berlin, false).Status); + } + + [Fact] + public void Monthly_readings_leave_the_month_to_date_complete_until_the_next_one_is_due() + { + // Read on the 15th: September to date is covered to the 15th, and the next reading is not due until October. + var now = At(2026, 9, 19, 10); + CoverageRun[] monthly = [Run(At(2026, 5, 15), At(2026, 9, 15), ResolutionClass.Month, divided: true, last: At(2026, 9, 1))]; + + var september = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), monthly, Berlin, false, now); + + Assert.Equal(BucketStatus.Available, september.Status); + } + + [Fact] + public void The_running_months_label_row_is_recorded_after_now_so_the_month_to_date_is_missing() + { + // A-04, evaluated from stored runs: the September row closes on 1 October, so nothing covers September + // yet, and a zero would not be a true zero. + var now = At(2026, 9, 19, 10); + CoverageRun[] stored = [MonthLabels(2026, 1, 2026, 10)]; + + var september = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), stored, Berlin, false, now); + var august = CoverageEvaluator.Evaluate(Month(2026, 8), stored, Berlin, false, now); + + Assert.Equal(BucketStatus.Missing, september.Status); + Assert.Equal(BucketStatus.Available, august.Status); + } + + [Fact] + public void A_future_stamped_daily_reading_does_not_cost_yesterday_its_coverage() + { + // m2 #5: readings at 06:00 with one already stamped tomorrow. Only the interval containing now is lost. + var now = At(2026, 9, 19, 10); + CoverageRun[] stored = [Run(At(2026, 9, 1, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))]; + + Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(Day(2026, 9, 18), stored, Berlin, false, now).Status); + Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), stored, Berlin, false, now).Status); + Assert.Equal(At(2026, 9, 19, 6), CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), stored, Berlin, false, now).LastCovered); + } + + // ---- Another zone ------------------------------------------------------------------------------------ + + [Fact] + public void In_New_York_monthly_labels_resolve_New_York_months_and_not_their_days() + { + var water = MonthLabels(NewYork, 2026, 1, 2026, 7); + + var march = CoverageEvaluator.Evaluate(Month(NewYork, 2026, 3), [water], NewYork, false); + var dstDay = CoverageEvaluator.Evaluate(Day(NewYork, 2026, 3, 8), [water], NewYork, false); + + Assert.Equal(BucketStatus.Available, march.Status); + Assert.Equal(TimeSpan.FromDays(31) - TimeSpan.FromHours(1), march.Length); + Assert.Equal(BucketStatus.Unresolved, dstDay.Status); + Assert.Equal(TimeSpan.FromHours(23), dstDay.Length); + } +} diff --git a/tests/Core.Tests/Analysis/CoverageRunsTests.cs b/tests/Core.Tests/Analysis/CoverageRunsTests.cs new file mode 100644 index 0000000..39a2b57 --- /dev/null +++ b/tests/Core.Tests/Analysis/CoverageRunsTests.cs @@ -0,0 +1,343 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using static MeterVault.Core.Tests.Analysis.CoverageTestData; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// Set operations on coverage runs: covered time, capping at now (D-04, D-13), and the joint coverage of a +/// virtual meter's sources (D-27) — where time counts only if every source covers it, as coarsely as the +/// coarsest source. +/// +public sealed class CoverageRunsTests +{ + [Fact] + public void Covered_time_unions_overlapping_runs_ignores_gaps_and_reports_the_outer_bounds() + { + CoverageRun[] runs = + [ + Run(At(2026, 3, 10), At(2026, 3, 20), ResolutionClass.Day), + Gap(At(2026, 3, 1), At(2026, 3, 10), CoverageGapReason.SampleGap), + Run(At(2026, 3, 15), At(2026, 3, 25), ResolutionClass.Hour), + Run(At(2026, 4, 1), At(2026, 4, 3), ResolutionClass.Hour), + ]; + + var covered = CoverageRuns.Covered(runs); + + Assert.Equal(TimeSpan.FromDays(17), covered.Total); + Assert.Equal(At(2026, 3, 10), covered.First); + Assert.Equal(At(2026, 4, 3), covered.Last); + Assert.False(covered.IsEmpty); + } + + [Fact] + public void Covered_time_inside_a_window_is_clipped_to_it() + { + var covered = CoverageRuns.Covered([Run(At(2026, 3, 10), At(2026, 3, 20), ResolutionClass.Day)], At(2026, 3, 15), At(2026, 4, 1)); + + Assert.Equal(TimeSpan.FromDays(5), covered.Total); + Assert.Equal(At(2026, 3, 15), covered.First); + } + + [Fact] + public void Nothing_covered_is_an_empty_span() + { + var covered = CoverageRuns.Covered([Gap(At(2026, 3, 1), At(2026, 3, 10), CoverageGapReason.SampleGap)]); + + Assert.True(covered.IsEmpty); + Assert.Equal(CoveredSpan.None, covered); + } + + [Fact] + public void A_current_month_label_row_is_recorded_after_now_so_coverage_ends_at_the_month_start() + { + // D-04: the September row describes all of September and is left out of actuals until it closes. + // Clipping at now would leave 1-19 September looking covered by a row nobody reads. + var now = At(2026, 9, 19, 10); + CoverageRun[] runs = + [ + MonthLabels(2026, 1, 2026, 10), + Run(At(2026, 10, 1), At(2026, 11, 1), ResolutionClass.Hour), + ]; + + var capped = CoverageRuns.CapAt(runs, now, Berlin); + + Assert.Equal([MonthLabels(2026, 1, 2026, 9) with { LastIntervalStart = null }], capped); + } + + [Fact] + public void Runs_that_end_by_now_are_kept_unchanged_and_runs_that_start_after_now_are_dropped() + { + var now = At(2026, 9, 19, 10); + var past = Run(At(2026, 1, 1), now, ResolutionClass.Hour, last: At(2026, 9, 19, 9)); + + Assert.Equal([past], CoverageRuns.CapAt([past, Run(now, At(2026, 10, 1), ResolutionClass.Hour)], now, Berlin)); + } + + [Fact] + public void A_future_stamped_daily_reading_gives_up_only_the_interval_containing_now() + { + // m2 #5: read at 06:00 daily, the next reading already stamped tomorrow. The run ends where the + // interval containing now starts, not at a midnight or a class limit before it. + var now = At(2026, 9, 19, 10); + + var daily = Assert.Single(CoverageRuns.CapAt( + [Run(At(2026, 9, 1, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))], now, Berlin)); + + Assert.Equal(Run(At(2026, 9, 1, 6), At(2026, 9, 19, 6), ResolutionClass.Day, divided: true), daily); + } + + [Fact] + public void Monthly_readings_with_one_in_the_future_keep_every_share_recorded_before_now() + { + // m2 #5: read on the 15th; the reading of 15 September is stamped in the future (now: 10 September). + // Its interval's August share closed on 1 September and is an actual, so August stays covered. + var now = At(2026, 9, 10, 10); + + var monthly = Assert.Single(CoverageRuns.CapAt( + [Run(At(2026, 5, 15), At(2026, 9, 15), ResolutionClass.Month, divided: true, last: At(2026, 9, 1))], now, Berlin)); + + Assert.Equal(At(2026, 9, 1), monthly.To); + } + + [Fact] + public void Weekly_readings_give_up_their_last_week_when_now_falls_inside_it() + { + // m2 #11: burner hours read every Monday at 10:00, the last reading stamped a week ahead. + var stored = Run(At(2026, 8, 3, 10), At(2026, 9, 21, 10), ResolutionClass.Week, divided: true, last: At(2026, 9, 14, 10)); + + var capped = Assert.Single(CoverageRuns.CapAt([stored], At(2026, 9, 19, 10), Berlin)); + + Assert.Equal(At(2026, 9, 14, 10), capped.To); + Assert.Null(capped.LastIntervalStart); + } + + [Fact] + public void Now_inside_an_earlier_interval_ends_the_run_one_interval_before_now() + { + // A-14: two readings stamped ahead. Only the final interval's start is stored, so the interval containing + // now (14 - 21 September) is not known; it started no earlier than one week-class interval before now, and + // claiming nothing after that never counts time whose row is recorded after now. + var stored = Run(At(2026, 8, 3, 10), At(2026, 9, 28, 10), ResolutionClass.Week, divided: true, last: At(2026, 9, 21, 10)); + + var capped = Assert.Single(CoverageRuns.CapAt([stored], At(2026, 9, 17, 10), Berlin)); + + Assert.Equal(At(2026, 9, 17, 10) - ResolutionClassifier.WeekLimit, capped.To); + Assert.Null(capped.LastIntervalStart); + } + + [Fact] + public void A_label_run_reaching_past_the_current_month_gives_up_the_current_month() + { + // Review F4: sheet rows July - October, the October row carrying September's register forward. Now (19 + // September) falls inside the September row's interval, not the last one; the September row closes after + // now, so September must not look covered — or it would read as a true zero. + var capped = Assert.Single(CoverageRuns.CapAt([MonthLabels(2026, 7, 2026, 11)], At(2026, 9, 19, 10), Berlin)); + + Assert.Equal(At(2026, 9, 1), capped.To); + } + + [Fact] + public void Month_shares_of_a_reading_stamped_weeks_ahead_keep_the_months_before_now() + { + // Review F4: read on 20 August, the next reading typed as 5 October. The August share closed on 1 September + // and is an actual; the September share closes on 30 September, after now. + var stored = Run(At(2026, 7, 20, 9), At(2026, 10, 5, 9), ResolutionClass.Month, divided: true, last: At(2026, 10, 1)); + + var capped = Assert.Single(CoverageRuns.CapAt([stored], At(2026, 9, 19, 14), Berlin)); + + Assert.Equal(At(2026, 9, 1), capped.To); + } + + [Fact] + public void Hourly_readings_with_two_stamped_ahead_give_up_one_hour_before_now_and_stay_up_to_date() + { + var now = At(2026, 9, 19, 10, 30); + var stored = Run(At(2026, 9, 1), At(2026, 9, 19, 13), ResolutionClass.Hour, divided: true, last: At(2026, 9, 19, 12)); + + var capped = Assert.Single(CoverageRuns.CapAt([stored], now, Berlin)); + Assert.Equal(now - ResolutionClassifier.HourLimit, capped.To); + + // A-04: today's bucket still counts as up to date — the coverage reaches within one interval of now. + var today = CutAt(Day(2026, 9, 19), now); + Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(today, [stored], Berlin, openingBalanceInBucket: false, now).Status); + } + + [Fact] + public void A_run_that_does_not_know_its_last_interval_is_cut_at_now() + { + var now = At(2026, 9, 19, 10); + + Assert.Equal(now, Assert.Single(CoverageRuns.CapAt([Run(At(2026, 9, 1), At(2026, 9, 20), ResolutionClass.Hour)], now, Berlin)).To); + } + + [Fact] + public void An_undivided_interval_longer_than_a_month_straddling_now_is_dropped_whole() + { + var now = At(2026, 9, 19, 10); + + Assert.Empty(CoverageRuns.CapAt([Single(At(2026, 8, 5), At(2026, 10, 5), ResolutionClass.Coarse)], now, Berlin)); + } + + [Fact] + public void A_divided_interval_straddling_now_keeps_its_shares_of_the_months_before() + { + // Read on 20 July and (stamped ahead) on 25 September: the September share is the run's last + // interval and closes after now; the July and August shares closed before it and are actuals. + var now = At(2026, 9, 19, 10); + + var capped = CoverageRuns.CapAt([Run(At(2026, 7, 20), At(2026, 9, 25), ResolutionClass.Month, divided: true, last: At(2026, 9, 1))], now, Berlin); + + Assert.Equal([Run(At(2026, 7, 20), At(2026, 9, 1), ResolutionClass.Month, divided: true)], capped); + } + + [Fact] + public void A_gap_straddling_now_is_clipped_at_now() + { + var now = At(2026, 9, 19, 10); + + var capped = CoverageRuns.CapAt([Gap(At(2026, 9, 18), At(2026, 9, 21), CoverageGapReason.SampleGap) with { LastIntervalStart = At(2026, 9, 18) }], now, Berlin); + + Assert.Equal(now, Assert.Single(capped).To); + } + + [Fact] + public void Zero_length_runs_claim_no_time_and_are_dropped() + { + // A-01: an opening balance is not a run; nothing zero-length survives capping. + var now = At(2026, 9, 19, 10); + + Assert.Empty(CoverageRuns.CapAt([Run(now.AddHours(-1), now.AddHours(-1), ResolutionClass.Hour)], now, Berlin)); + } + + [Fact] + public void Capping_twice_at_the_same_instant_changes_nothing() + { + var now = At(2026, 9, 19, 10); + CoverageRun[] stored = + [ + MonthLabels(2026, 1, 2026, 10), + Run(At(2026, 9, 1, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6)), + Run(At(2026, 9, 1), At(2026, 9, 20), ResolutionClass.Hour), + ]; + + var once = CoverageRuns.CapAt(stored, now, Berlin); + + Assert.Equal(once, CoverageRuns.CapAt(once, now, Berlin)); + } + + [Fact] + public void In_New_York_the_running_months_label_is_given_up_at_the_New_York_month_start() + { + var now = At(NewYork, 2026, 9, 19, 10); + + var capped = Assert.Single(CoverageRuns.CapAt([MonthLabels(NewYork, 2026, 1, 2026, 10)], now, NewYork)); + + Assert.Equal(At(NewYork, 2026, 9, 1), capped.To); + Assert.Equal(new DateTimeOffset(2026, 9, 1, 4, 0, 0, TimeSpan.Zero), capped.To); + } + + [Fact] + public void The_joint_coverage_of_a_monthly_and_an_hourly_source_is_their_overlap_at_month_resolution() + { + var joint = CoverageRuns.Intersect( + [ + [MonthLabels(2026, 1, 2026, 7)], + [Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)], + ]); + + var run = Assert.Single(joint); + Assert.Equal(At(2026, 3, 15, 12), run.From); + Assert.Equal(At(2026, 7, 1), run.To); + Assert.Equal(ResolutionClass.Month, run.Resolution); + + // Only divided when every source was: the hourly samples were never divided. + Assert.False(run.DividedAtMonths); + } + + [Fact] + public void Joint_runs_split_where_the_coarsest_class_changes_and_merge_where_it_does_not() + { + var joint = CoverageRuns.Intersect( + [ + [ + Run(At(2026, 1, 1), At(2026, 2, 1), ResolutionClass.Hour), + Run(At(2026, 2, 1), At(2026, 3, 1), ResolutionClass.Hour), + Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Week), + ], + [Run(At(2026, 1, 1), At(2026, 4, 1), ResolutionClass.Day)], + ]); + + Assert.Equal( + [ + Run(At(2026, 1, 1), At(2026, 3, 1), ResolutionClass.Day), + Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Week), + ], + joint); + } + + [Fact] + public void Joint_coverage_is_divided_only_where_every_source_is() + { + var joint = CoverageRuns.Intersect( + [ + [MonthLabels(2025, 1, 2026, 1)], + [MonthLabels(2025, 1, 2025, 7), Run(At(2025, 7, 1), At(2026, 1, 1), ResolutionClass.Month)], + ]); + + // Joint runs do not know where any source's last interval starts. + Assert.Equal( + [ + MonthLabels(2025, 1, 2025, 7) with { LastIntervalStart = null }, + Run(At(2025, 7, 1), At(2026, 1, 1), ResolutionClass.Month), + ], + joint); + } + + [Fact] + public void A_hole_in_one_source_is_a_hole_in_the_joint_coverage_and_keeps_its_reason() + { + var outage = Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap); + + var joint = CoverageRuns.Intersect( + [ + [Run(At(2026, 3, 1), At(2026, 3, 10, 14), ResolutionClass.Hour), outage, Run(At(2026, 3, 13, 9), At(2026, 4, 1), ResolutionClass.Hour)], + [Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Day)], + ]); + + Assert.Equal( + [ + Run(At(2026, 3, 1), At(2026, 3, 10, 14), ResolutionClass.Day), + outage, + Run(At(2026, 3, 13, 9), At(2026, 4, 1), ResolutionClass.Day), + ], + joint); + } + + [Fact] + public void A_source_without_coverage_leaves_no_joint_coverage() + { + var joint = CoverageRuns.Intersect( + [ + [Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Hour)], + [], + ]); + + Assert.Empty(joint); + Assert.Empty(CoverageRuns.Intersect([])); + } + + [Fact] + public void A_single_source_intersects_to_its_own_coverage_with_touching_runs_merged() + { + var joint = CoverageRuns.Intersect( + [ + [ + Run(At(2026, 3, 1), At(2026, 3, 15), ResolutionClass.Hour), + Run(At(2026, 3, 15), At(2026, 4, 1), ResolutionClass.Hour), + ], + ]); + + Assert.Equal([Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Hour)], joint); + } +} diff --git a/tests/Core.Tests/Analysis/CoverageTestData.cs b/tests/Core.Tests/Analysis/CoverageTestData.cs new file mode 100644 index 0000000..974df50 --- /dev/null +++ b/tests/Core.Tests/Analysis/CoverageTestData.cs @@ -0,0 +1,134 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Normalization; + +namespace MeterVault.Core.Tests.Analysis; + +/// Terse builders for coverage tests: local instants, local buckets and coverage runs (Berlin unless a zone is given). +internal static class CoverageTestData +{ + public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); + + public static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + + /// A Berlin wall-clock time as an instant (UTC). + public static DateTimeOffset At(int year, int month, int day, int hour = 0, int minute = 0) => At(Berlin, year, month, day, hour, minute); + + /// A wall-clock time in as an instant (UTC). + public static DateTimeOffset At(TimeZoneInfo zone, int year, int month, int day, int hour = 0, int minute = 0) + { + var wall = new DateTime(year, month, day, hour, minute, 0); + return new DateTimeOffset(wall, zone.GetUtcOffset(wall)).ToUniversalTime(); + } + + public static DateTimeOffset Midnight(DateOnly date) => GapAttribution.LocalMidnight(date, Berlin); + + public static AnalysisBucket Day(int year, int month, int day) => Day(Berlin, year, month, day); + + public static AnalysisBucket Day(TimeZoneInfo zone, int year, int month, int day) + { + var date = new DateOnly(year, month, day); + return Bucket(zone, date, date.AddDays(1), BucketSize.Day); + } + + /// The Monday-start week containing the given date. + public static AnalysisBucket Week(int year, int month, int day) + { + var date = new DateOnly(year, month, day); + var monday = date.AddDays(-(((int)date.DayOfWeek + 6) % 7)); + return Bucket(Berlin, monday, monday.AddDays(7), BucketSize.Week); + } + + public static AnalysisBucket Month(int year, int month) => Month(Berlin, year, month); + + public static AnalysisBucket Month(TimeZoneInfo zone, int year, int month) + { + var first = new DateOnly(year, month, 1); + return Bucket(zone, first, first.AddMonths(1), BucketSize.Month); + } + + public static AnalysisBucket Year(int year) + { + var first = new DateOnly(year, 1, 1); + return Bucket(Berlin, first, first.AddYears(1), BucketSize.Year); + } + + /// A bucket cut at : the to-date bucket of a period that ends now. + public static AnalysisBucket CutAt(AnalysisBucket bucket, DateTimeOffset now) => + bucket with { To = now, EndDay = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, Berlin).DateTime).AddDays(1), NominalEndDay = bucket.EndDay }; + + public static CoverageRun Run(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false, DateTimeOffset? last = null) => + new(from, to, resolution, divided, CoverageGapReason.None, last); + + /// A run that is one source interval: it knows its final interval starts where it does. + public static CoverageRun Single(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false) => + Run(from, to, resolution, divided, last: from); + + public static CoverageRun Gap(DateTimeOffset from, DateTimeOffset to, CoverageGapReason reason) => + new(from, to, ResolutionClass.Hour, false, reason); + + /// + /// A run of imported monthly table rows ("Jan 2022" … ): each row's interval is its labelled local month, + /// so the run is month-class, month-aligned, and its last interval is its last month. + /// + public static CoverageRun MonthLabels(int fromYear, int fromMonth, int toYear, int toMonthExclusive) => + MonthLabels(Berlin, fromYear, fromMonth, toYear, toMonthExclusive); + + public static CoverageRun MonthLabels(TimeZoneInfo zone, int fromYear, int fromMonth, int toYear, int toMonthExclusive) + { + var end = new DateOnly(toYear, toMonthExclusive, 1); + return Run( + GapAttribution.LocalMidnight(new DateOnly(fromYear, fromMonth, 1), zone), + GapAttribution.LocalMidnight(end, zone), + ResolutionClass.Month, + divided: true, + last: GapAttribution.LocalMidnight(end.AddMonths(-1), zone)); + } + + /// + /// The calendar shift of a comparison (D-06), as the period resolver maps a cut-off: the same local wall + /// time on the shifted date. A date the target month lacks (29 February, 31 April) cuts at that month's + /// end; a wall time lost to DST takes the first instant after the gap; a repeated one its first occurrence. + /// + public static DateTimeOffset ShiftYears(DateTimeOffset instant, int years) => Shift(instant, Berlin, months: 12 * years); + + public static DateTimeOffset ShiftMonths(DateTimeOffset instant, int months) => Shift(instant, Berlin, months: months); + + public static DateTimeOffset ShiftDays(DateTimeOffset instant, int days) => Shift(instant, Berlin, days: days); + + public static DateTimeOffset Shift(DateTimeOffset instant, TimeZoneInfo zone, int months = 0, int days = 0) + { + var wall = TimeZoneInfo.ConvertTime(instant, zone).DateTime; + if (days != 0) + { + return FromWall(wall.AddDays(days), zone); + } + + var target = wall.AddMonths(months); + return target.Day != wall.Day + ? GapAttribution.LocalMidnight(new DateOnly(target.Year, target.Month, 1).AddMonths(1), zone) + : FromWall(target, zone); + } + + public static ResolvedPeriod Period(PeriodPreset preset, DateOnly firstDay, DateOnly lastDay, DateTimeOffset now) + { + var from = Midnight(firstDay); + var endOfLastDay = Midnight(lastDay.AddDays(1)); + var toDate = now < endOfLastDay; + return new ResolvedPeriod(preset, firstDay, lastDay, from, toDate ? now : endOfLastDay, now, toDate, false, Berlin); + } + + private static AnalysisBucket Bucket(TimeZoneInfo zone, DateOnly first, DateOnly end, BucketSize size) => + new(first, end, GapAttribution.LocalMidnight(first, zone), GapAttribution.LocalMidnight(end, zone), size); + + private static DateTimeOffset FromWall(DateTime wall, TimeZoneInfo zone) + { + var local = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified); + while (zone.IsInvalidTime(local)) + { + local = local.AddMinutes(1); + } + + var offset = zone.IsAmbiguousTime(local) ? zone.GetAmbiguousTimeOffsets(local).Max() : zone.GetUtcOffset(local); + return new DateTimeOffset(local, offset).ToUniversalTime(); + } +} diff --git a/tests/Core.Tests/Analysis/DependencyGraphTests.cs b/tests/Core.Tests/Analysis/DependencyGraphTests.cs new file mode 100644 index 0000000..bf2687c --- /dev/null +++ b/tests/Core.Tests/Analysis/DependencyGraphTests.cs @@ -0,0 +1,108 @@ +using MeterVault.Core.Analysis.Virtual; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// Nested virtual meters are evaluated dependencies first, a loop is reported with the path through it and +/// withheld from evaluation, and a request expands to the physical meters it finally reads (D-27). +/// +public sealed class DependencyGraphTests +{ + private static DependencyGraph Graph(params (int Meter, int[] Dependencies)[] meters) => + DependencyGraph.Build(meters.ToDictionary(m => m.Meter, m => (IReadOnlyList)m.Dependencies)); + + [Fact] + public void Nested_virtual_meters_are_ordered_dependencies_first_and_expand_to_their_physical_leaves() + { + // 10 = m1 + m2, 11 = m10 - m3, 12 = m11 + m10 + var graph = Graph((12, [11, 10]), (11, [10, 3]), (10, [1, 2])); + + Assert.Equal([10, 11, 12], graph.EvaluationOrder); + Assert.Equal([1, 2, 3], graph.PhysicalLeaves(12)); + Assert.Equal([1, 2], graph.PhysicalLeaves(10)); + Assert.Equal([7], graph.PhysicalLeaves(7)); + Assert.Equal([12, 10, 1], graph.PathTo(12, 1)); // the shortest way, not 12 → 11 → 10 → 1 + Assert.Null(graph.PathTo(10, 3)); + Assert.Empty(graph.Cycles); + } + + [Fact] + public void Evaluation_order_for_a_request_holds_only_what_it_reaches() + { + var graph = Graph((12, [11]), (11, [10]), (10, [1]), (20, [2])); + + Assert.Equal([10, 11], graph.EvaluationOrderFor([11])); + Assert.Equal([20], graph.EvaluationOrderFor([20, 1])); + } + + [Fact] + public void A_loop_is_reported_with_its_path_and_blocks_everything_that_depends_on_it() + { + // 10 → 11 → 12 → 10 is a loop; 13 reads it; 14 is independent. + var graph = Graph((10, [11]), (11, [12, 1]), (12, [10]), (13, [10, 2]), (14, [1])); + + var cycle = Assert.Single(graph.Cycles); + Assert.Equal([10, 11, 12, 10], cycle); + Assert.Equal([11, 12, 10, 11], graph.CycleFor(11)); + Assert.Equal([13, 10, 11, 12, 10], graph.CycleFor(13)); + Assert.False(graph.IsEvaluable(13)); + Assert.True(graph.IsEvaluable(14)); + Assert.True(graph.IsEvaluable(1)); + Assert.Null(graph.CycleFor(14)); + Assert.Equal([14], graph.EvaluationOrder); + } + + [Fact] + public void A_meter_referring_to_itself_is_a_loop_of_one() + { + var graph = Graph((10, [10, 1])); + + Assert.Equal([10, 10], Assert.Single(graph.Cycles)); + Assert.Empty(graph.EvaluationOrder); + Assert.Equal([1], graph.PhysicalLeaves(10)); + } + + [Fact] + public void Dependents_are_the_virtual_meters_a_deletion_would_break() + { + var graph = Graph((10, [1, 2]), (11, [10, 3]), (12, [3])); + + Assert.Equal([10, 11], graph.Dependents(1)); + Assert.Equal([11, 12], graph.Dependents(3)); + Assert.Equal([11], graph.Dependents(10)); + Assert.Empty(graph.Dependents(11)); + } + + [Fact] + public void A_legacy_virtual_meter_without_a_calculation_is_not_mistaken_for_a_physical_leaf() + { + var catalog = new MeterCatalog(VirtualFixtures.SeededElectricity()); // Summe Solar (6) has no definition + + var graph = DependencyGraph.FromCatalog(catalog); + + Assert.True(graph.IsVirtual(6)); + Assert.Empty(graph.PhysicalLeaves(6)); + Assert.False(graph.IsVirtual(4)); + } + + [Fact] + public void A_chain_of_five_thousand_virtual_meters_is_walked_without_recursion() + { + var chain = Enumerable.Range(1, 5000).Select(i => (Meter: 10_000 + i, Dependencies: new[] { i == 1 ? 1 : 10_000 + i - 1 })).ToArray(); + var graph = Graph(chain); + + Assert.Equal(5000, graph.EvaluationOrder.Count); + Assert.Equal(10_001, graph.EvaluationOrder[0]); + Assert.Equal([1], graph.PhysicalLeaves(15_000)); + Assert.Equal(5001, graph.PathTo(15_000, 1)!.Count); + + var looped = Graph([.. chain.Skip(1), (10_001, [15_000])]); + Assert.Equal(5001, Assert.Single(looped.Cycles).Count); + } + + [Fact] + public void Paths_format_as_meter_ids_joined_by_the_separator() + { + Assert.Equal("12>7>12", DependencyGraph.FormatPath([12, 7, 12])); + } +} diff --git a/tests/Core.Tests/Analysis/EngineIntervalTests.cs b/tests/Core.Tests/Analysis/EngineIntervalTests.cs new file mode 100644 index 0000000..0ee0e97 --- /dev/null +++ b/tests/Core.Tests/Analysis/EngineIntervalTests.cs @@ -0,0 +1,761 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; +using MeterVault.Core.Normalization.Normalizers; +using static MeterVault.Core.Tests.Analysis.AnalysisTestTime; +using static MeterVault.Core.Tests.TestData; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// Every normalized row says which stretch of time it accrued over (D-10), and a row that closes at a +/// local midnight is booked in the day it closes (D-11). Coverage, rollups and bucket status are built +/// on these intervals, so each mode is pinned here — including the first reading, whose start is only +/// known from a month label or an install date. +/// +public sealed class EngineIntervalTests +{ + private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); + + private static Reading Manual(DateTimeOffset time, double value, int meterId = 1) => + new() { MeterId = meterId, Time = time, Value = value, Quality = ReadingQuality.Manual }; + + private static Reading Measured(DateTimeOffset time, double value, int meterId = 1) => + new() { MeterId = meterId, Time = time, Value = value, Quality = ReadingQuality.Measured }; + + private static NormalizationContext Context( + MeterMode mode, TimeZoneInfo zone, IReadOnlyList readings, IReadOnlyList? events = null, DateOnly? installedAt = null) => + new() + { + Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "kWh", InstalledAt = installedAt }, + Readings = readings, + Events = events ?? [], + TimeZone = zone, + }; + + private static TimeZoneInfo Zone(string id) => id == "UTC" ? TimeZoneInfo.Utc : TimeZoneInfo.FindSystemTimeZoneById(id); + + // ---- Registers ------------------------------------------------------------------------------- + + [Fact] + public void Each_register_row_covers_the_time_since_the_previous_reading() + { + var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, + [ + Manual(InBerlin(2026, 9, 2, 8), 100), + Manual(InBerlin(2026, 9, 10, 20), 110), + Manual(InBerlin(2026, 9, 20, 6), 125), + ])); + + Assert.Equal(3, result.Count); + Assert.Equal([InBerlin(2026, 9, 2, 8), InBerlin(2026, 9, 10, 20)], result.Skip(1).Select(c => c.IntervalStart!.Value)); + Assert.Equal([InBerlin(2026, 9, 10, 20), InBerlin(2026, 9, 20, 6)], result.Skip(1).Select(c => c.IntervalEnd!.Value)); + Assert.All(result.Skip(1), c => + { + Assert.False(c.Divided); + Assert.False(c.OpeningBalance); + Assert.Equal(CoverageGapReason.None, c.Gap); + }); + } + + [Fact] + public void A_first_reading_without_a_label_or_install_date_is_an_opening_balance_with_an_unknown_start() + { + var first = Assert.Single(_engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)]))); + + Assert.True(first.OpeningBalance); + Assert.Equal(300, first.Amount); + Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalStart); + Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalEnd); + Assert.Equal(InBerlin(2026, 9, 2, 8), first.Time); + } + + [Fact] + public void A_first_reading_starts_at_the_local_midnight_of_the_install_date() + { + var first = Assert.Single(_engine.Normalize(Context( + MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)], installedAt: new DateOnly(2026, 8, 15)))); + + Assert.False(first.OpeningBalance); + Assert.False(first.Divided); // the baseline delta is booked at its reading, as it always was + Assert.Equal(Utc(2026, 8, 14, 22), first.IntervalStart); // 15 August 00:00 in Berlin + Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalEnd); + Assert.Equal(InBerlin(2026, 9, 2, 8), first.Time); + } + + [Fact] + public void An_install_date_after_the_first_reading_says_nothing_about_its_start() + { + var first = Assert.Single(_engine.Normalize(Context( + MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)], installedAt: new DateOnly(2026, 9, 5)))); + + Assert.True(first.OpeningBalance); + Assert.Equal(first.IntervalEnd, first.IntervalStart); + } + + [Fact] + public void A_first_month_row_covers_the_local_month_it_names() + { + // Zähler Auto's first sheet row, "Mai 2023" = 3755: the whole register booked as May's figure. + var first = Assert.Single(_engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Reading(1, Month(2023, 5), 3755)]))); + + Assert.False(first.OpeningBalance); + Assert.Equal(InBerlin(2023, 5, 1), first.IntervalStart); + Assert.Equal(InBerlin(2023, 6, 1), first.IntervalEnd); + Assert.Equal(Month(2023, 5), first.Time); + } + + [Theory] + [InlineData("UTC")] + [InlineData("Europe/Berlin")] + public void Consecutive_month_rows_keep_their_stamps_and_span_exactly_their_months(string zoneId) + { + // The golden-fixture shape. A label closes at a local midnight (its month's end) but is a month + // figure, not a reading at that instant: it keeps its stamp. + var zone = Zone(zoneId); + var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, zone, + [ + Reading(1, Month(2022, 9), 0), + Reading(1, Month(2022, 10), 411), + Reading(1, Month(2022, 11), 1153), + Reading(1, Month(2022, 12), 1968), + ])); + + Assert.Equal([Month(2022, 9), Month(2022, 10), Month(2022, 11), Month(2022, 12)], result.Select(c => c.Time)); + Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray()); + DateTimeOffset MonthStart(int month) => GapAttribution.LocalMidnight(new DateOnly(2022, month, 1), zone); + Assert.Equal([MonthStart(9), MonthStart(10), MonthStart(11), MonthStart(12)], result.Select(c => c.IntervalStart!.Value)); + Assert.Equal([MonthStart(10), MonthStart(11), MonthStart(12), GapAttribution.LocalMidnight(new DateOnly(2023, 1, 1), zone)], + result.Select(c => c.IntervalEnd!.Value)); + Assert.DoesNotContain(result, c => c.Divided || c.OpeningBalance || c.Gap != CoverageGapReason.None); + } + + [Fact] + public void An_interval_divided_at_a_month_boundary_gives_each_share_its_own_segment() + { + var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, + [ + Manual(InBerlin(2026, 8, 1, 9), 700), + Manual(InBerlin(2026, 9, 16, 18), 746), + ])); + var shares = result.Skip(1).ToList(); + + Assert.Equal(2, shares.Count); + Assert.All(shares, c => + { + Assert.True(c.Divided); + Assert.Equal(ReadingQuality.Estimated, c.Quality); + }); + Assert.Equal(InBerlin(2026, 8, 1, 9), shares[0].IntervalStart); + Assert.Equal(InBerlin(2026, 9, 1), shares[0].IntervalEnd); + Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), shares[0].Time); + Assert.Equal(InBerlin(2026, 9, 1), shares[1].IntervalStart); + Assert.Equal(InBerlin(2026, 9, 16, 18), shares[1].IntervalEnd); + Assert.Equal(InBerlin(2026, 9, 16, 18), shares[1].Time); + Assert.Equal(46, shares.Sum(c => c.Amount), 9); + } + + [Fact] + public void Attribution_segments_tile_the_interval_at_local_month_starts() + { + var from = InBerlin(2026, 5, 20, 7); + var to = InBerlin(2026, 8, 3, 21); + + var segments = GapAttribution.Attribute(from, to, to, 1000, Berlin); + + Assert.Equal([from, InBerlin(2026, 6, 1), InBerlin(2026, 7, 1), InBerlin(2026, 8, 1)], segments.Select(s => s.From)); + Assert.Equal([InBerlin(2026, 6, 1), InBerlin(2026, 7, 1), InBerlin(2026, 8, 1), to], segments.Select(s => s.To)); + Assert.All(segments, s => Assert.True(s.Time >= s.From && s.Time <= s.To, $"{s.Time:O} lies outside its segment")); + } + + [Fact] + public void An_unexplained_decrease_marks_its_interval_as_a_gap() + { + var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, TimeZoneInfo.Utc, + [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 60), Reading(1, Month(2023, 3), 70)])); + + Assert.Equal([CoverageGapReason.None, CoverageGapReason.UnexplainedDecrease, CoverageGapReason.None], result.Select(c => c.Gap)); + Assert.Equal(Month(2023, 2), result[1].IntervalStart); + Assert.Equal(Month(2023, 3), result[1].IntervalEnd); + Assert.Equal(0, result[1].Amount); + } + + [Theory] + [InlineData(null, CoverageGapReason.ResetWithoutPrevious)] + [InlineData(170d, CoverageGapReason.None)] + public void A_reset_is_a_gap_only_when_it_does_not_say_where_the_old_register_stopped(double? prevValue, CoverageGapReason expected) + { + var result = _engine.Normalize(new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh", InitialBaseline = 90 }, + Readings = [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 150), Reading(1, Month(2023, 3), 30)], + Events = [Reset(1, Month(2023, 3), newValue: 0, prevValue: prevValue)], + }); + + Assert.Equal(expected, result[^1].Gap); + Assert.Equal(Month(2023, 3), result[^1].IntervalStart); + } + + [Fact] + public void A_swap_with_its_amount_is_covered_time() + { + // The water register …861 → 2 books 12 m³ across the swap month: no hole. + var result = _engine.Normalize(new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" }, + Readings = [Reading(1, Month(2023, 1), 848), Reading(1, Month(2023, 2), 861), Reading(1, Month(2023, 3), 2), Reading(1, Month(2023, 4), 15)], + Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)], + }); + + Assert.DoesNotContain(result, c => c.Gap != CoverageGapReason.None); + Assert.Equal(Month(2023, 3), result[2].IntervalStart); + Assert.Equal(Month(2023, 4), result[2].IntervalEnd); + } + + [Fact] + public void Every_row_names_the_whole_reading_interval_it_came_from() + { + // A-03: shares are classified and told apart by their source interval, not by their own segment. + var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, + [ + Manual(InBerlin(2026, 8, 2, 9), 700), + Manual(InBerlin(2026, 8, 10, 9), 720), + Manual(InBerlin(2026, 9, 16, 18), 766), + ])); + + var inside = result.Single(c => c.IntervalEnd == InBerlin(2026, 8, 10, 9)); + var shares = result.Where(c => c.Divided).ToList(); + + Assert.Equal((inside.IntervalStart, inside.IntervalEnd), (inside.SourceStart, inside.SourceEnd)); + Assert.Equal(2, shares.Count); + Assert.All(shares, c => + { + Assert.Equal(InBerlin(2026, 8, 10, 9), c.SourceStart); + Assert.Equal(InBerlin(2026, 9, 16, 18), c.SourceEnd); + }); + Assert.NotEqual(shares[0].IntervalEnd, shares[1].IntervalEnd); + } + + [Fact] + public void A_register_that_stands_still_across_a_month_boundary_is_one_row_exact_in_every_month() + { + // m7 #2, A-02: nothing moved from 20 June to 20 July. One row, zero, flagged as month-exact, and not + // estimated — nothing about it was inferred. + var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, + [Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 130)])); + + var still = result[^1]; + Assert.Equal(2, result.Count); + Assert.Equal(0, still.Amount); + Assert.True(still.Divided); + Assert.Equal(ReadingQuality.Manual, still.Quality); + Assert.Equal(InBerlin(2026, 6, 20, 12), still.IntervalStart); + Assert.Equal(InBerlin(2026, 7, 20, 12), still.IntervalEnd); + } + + [Fact] + public void A_standstill_inside_one_month_or_a_rejected_decrease_is_not_flagged() + { + var inside = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, + [Manual(InBerlin(2026, 6, 2, 12), 130), Manual(InBerlin(2026, 6, 20, 12), 130)])); + var decrease = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, + [Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 90)])); + + Assert.False(inside[^1].Divided); + Assert.False(decrease[^1].Divided); + Assert.Equal(CoverageGapReason.UnexplainedDecrease, decrease[^1].Gap); + } + + [Theory] + [InlineData(MeterMode.RuntimeCounter)] + [InlineData(MeterMode.DirectDelta)] + [InlineData(MeterMode.GenerationCounter)] + public void Zero_across_a_month_boundary_is_exact_in_every_month_for_every_register_like_mode(MeterMode mode) + { + var first = mode == MeterMode.DirectDelta ? 4 : 130; + var second = mode == MeterMode.DirectDelta ? 0 : 130; + + var result = _engine.Normalize(Context(mode, Berlin, + [Manual(InBerlin(2026, 6, 20, 12), first), Manual(InBerlin(2026, 7, 20, 12), second)])); + + Assert.Equal(0, result[^1].Amount); + Assert.True(result[^1].Divided); + Assert.Equal(ReadingQuality.Manual, result[^1].Quality); + } + + [Fact] + public void Burner_hours_that_moved_across_a_month_boundary_stay_undivided() + { + var result = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, + [Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 150)])); + + Assert.False(result[^1].Divided); + Assert.Equal(20, result[^1].Amount); + } + + // ---- Midnight stamps (D-11) ------------------------------------------------------------------ + + [Theory] + [InlineData("Europe/Berlin")] + [InlineData("America/New_York")] + [InlineData("UTC")] + public void A_daily_snapshot_at_local_midnight_is_booked_in_the_day_it_closes(string zoneId) + { + var zone = Zone(zoneId); + var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, zone, + [ + Measured(Local(zone, 2026, 9, 10), 100), + Measured(Local(zone, 2026, 9, 11), 105), + Measured(Local(zone, 2026, 9, 12), 112), + ])); + + // The opening balance describes no time before its midnight, so it stays on it. + Assert.Equal(Local(zone, 2026, 9, 10), result[0].Time); + Assert.Equal([Local(zone, 2026, 9, 10, 23, 59, 59), Local(zone, 2026, 9, 11, 23, 59, 59)], result.Skip(1).Select(c => c.Time)); + Assert.Equal([new DateOnly(2026, 9, 10), new DateOnly(2026, 9, 11)], result.Skip(1).Select(c => LocalDate(c.Time, zone))); + Assert.Equal([5d, 7d], result.Skip(1).Select(c => c.Amount)); + Assert.Equal([Local(zone, 2026, 9, 11), Local(zone, 2026, 9, 12)], result.Skip(1).Select(c => c.IntervalEnd!.Value)); + Assert.All(result.Skip(1), c => Assert.Equal(ReadingQuality.Measured, c.Quality)); + } + + [Fact] + public void A_daily_snapshot_after_the_long_autumn_day_is_stamped_inside_that_day() + { + // 25 October 2026 has 25 hours in Berlin. + var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, + [Measured(InBerlin(2026, 10, 25), 100), Measured(InBerlin(2026, 10, 26), 110)])); + + Assert.Equal(new DateOnly(2026, 10, 25), LocalDate(result[1].Time, Berlin)); + Assert.Equal(TimeSpan.FromHours(25), result[1].IntervalEnd - result[1].IntervalStart); + } + + [Theory] + [InlineData(MeterMode.CumulativeCounter)] + [InlineData(MeterMode.RuntimeCounter)] + [InlineData(MeterMode.DirectDelta)] + [InlineData(MeterMode.InstantRate)] + public void The_midnight_that_ends_a_month_books_its_row_on_that_months_last_day(MeterMode mode) + { + var result = _engine.Normalize(Context(mode, Berlin, + [Manual(InBerlin(2026, 8, 20, 12), 100), Manual(InBerlin(2026, 9, 1), 130)])); + + var closing = result[^1]; + Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), closing.Time); + Assert.Equal(InBerlin(2026, 8, 20, 12), closing.IntervalStart); + Assert.Equal(InBerlin(2026, 9, 1), closing.IntervalEnd); + } + + [Fact] + public void A_divided_interval_that_closes_at_a_midnight_stamps_its_last_share_in_the_day_before() + { + var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, + [Manual(InBerlin(2026, 8, 20, 12), 100), Manual(InBerlin(2026, 9, 10), 142)])); + var shares = result.Skip(1).ToList(); + + Assert.Equal([InBerlin(2026, 8, 31, 23, 59, 59), InBerlin(2026, 9, 9, 23, 59, 59)], shares.Select(c => c.Time)); + Assert.Equal([InBerlin(2026, 9, 1), InBerlin(2026, 9, 10)], shares.Select(c => c.IntervalEnd!.Value)); + Assert.All(shares, c => Assert.True(c.Divided)); + } + + [Fact] + public void A_day_dated_import_at_utc_midnight_closes_the_day_before_only_where_that_is_local_midnight() + { + // The importer stamps "20.07.2026" at 00:00 UTC. In a UTC instance that is the midnight that ends + // 19 July; in Berlin it is 02:00 on the 20th, an instant inside the day. + Reading[] readings = [DayReading(1, Utc(2026, 7, 15), 600), DayReading(1, Utc(2026, 7, 20), 650)]; + + var inUtc = _engine.Normalize(Context(MeterMode.RuntimeCounter, TimeZoneInfo.Utc, readings)); + var inBerlin = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, readings)); + + Assert.Equal(Utc(2026, 7, 20).AddSeconds(-1), inUtc[^1].Time); + Assert.Equal(Utc(2026, 7, 20), inBerlin[^1].Time); + Assert.Equal(Utc(2026, 7, 20), inUtc[^1].IntervalEnd); + } + + [Fact] + public void Where_midnight_happens_twice_only_the_first_starts_the_day() + { + // Havana leaves daylight time at 01:00 on 1 November 2026: 00:00 is at 04:00 and again at 05:00 UTC. + var havana = TimeZoneInfo.FindSystemTimeZoneById("America/Havana"); + + Assert.True(GapAttribution.IsLocalMidnight(Utc(2026, 11, 1, 4), havana)); + Assert.False(GapAttribution.IsLocalMidnight(Utc(2026, 11, 1, 5), havana)); + Assert.True(GapAttribution.IsLocalMonthStart(Utc(2026, 11, 1, 4), havana)); + } + + [Fact] + public void A_midnight_stamp_never_leaves_an_interval_shorter_than_a_second() + { + var end = InBerlin(2026, 9, 1); + var start = end.AddMilliseconds(-400); + + var stamp = GapAttribution.CloseStamp(start, end, Berlin); + + Assert.True(stamp > start && stamp < end, $"{stamp:O} lies outside the interval"); + Assert.Equal(end, GapAttribution.CloseStamp(end, end, Berlin)); + Assert.Equal(InBerlin(2026, 9, 1, 0, 0, 1), GapAttribution.CloseStamp(start, InBerlin(2026, 9, 1, 0, 0, 1), Berlin)); + } + + // ---- Runtime --------------------------------------------------------------------------------- + + [Fact] + public void Burner_rows_run_from_the_previous_reading_and_a_long_silence_stays_one_interval() + { + // The seeded burner: 0 h on 18.10.2010, then nothing until 7758 h on 14.10.2022. + var result = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, + [ + DayReading(1, Utc(2010, 10, 18), 0), + DayReading(1, Utc(2022, 10, 14), 7758), + DayReading(1, Utc(2022, 11, 28), 7758), + ])); + + Assert.Equal(3, result.Count); + Assert.True(result[0].OpeningBalance); + Assert.Equal(Utc(2010, 10, 18), result[1].IntervalStart); + Assert.Equal(Utc(2022, 10, 14), result[1].IntervalEnd); + Assert.Equal(7758, result[1].Amount); + Assert.Equal(Utc(2022, 10, 14), result[1].Time); + Assert.Equal(Utc(2022, 10, 14), result[2].IntervalStart); + + // The twelve years of hours are not divided; the burner that then stood still across 1 November is + // exactly zero in October and November alike (A-02). + Assert.False(result[1].Divided); + Assert.True(result[2].Divided); + Assert.Equal(0, result[2].Amount); + } + + [Fact] + public void Burner_hours_that_go_backwards_or_reset_blind_are_gaps() + { + var result = _engine.Normalize(new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.RuntimeCounter, Unit = "h" }, + Readings = [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 40), Reading(1, Month(2023, 3), 50), Reading(1, Month(2023, 4), 10)], + Events = [Reset(1, Month(2023, 4), newValue: 0)], + }); + + Assert.Equal( + [CoverageGapReason.None, CoverageGapReason.UnexplainedDecrease, CoverageGapReason.None, CoverageGapReason.ResetWithoutPrevious], + result.Select(c => c.Gap)); + Assert.Equal([100d, 0d, 10d, 10d], result.Select(c => c.Amount)); + } + + [Fact] + public void A_first_burner_reading_covers_its_label_month_or_starts_at_the_install_date() + { + var label = Assert.Single(_engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, [Reading(1, Month(2023, 1), 7952)]))); + var installed = Assert.Single(_engine.Normalize(Context( + MeterMode.RuntimeCounter, Berlin, [Manual(InBerlin(2023, 1, 20, 10), 12)], installedAt: new DateOnly(2023, 1, 2)))); + + Assert.Equal(InBerlin(2023, 1, 1), label.IntervalStart); + Assert.Equal(InBerlin(2023, 2, 1), label.IntervalEnd); + Assert.False(label.OpeningBalance); + Assert.Equal(InBerlin(2023, 1, 2), installed.IntervalStart); + Assert.False(installed.OpeningBalance); + } + + // ---- Tank ------------------------------------------------------------------------------------ + + [Fact] + public void A_tank_row_covers_the_time_between_two_dipsticks_and_early_deliveries_cover_nothing() + { + var result = _engine.Normalize(new NormalizationContext + { + Meter = new MeterConfig + { + MeterId = 30, + Mode = MeterMode.ConsumableBalance, + Unit = "L", + Tank = new TankConfig { Capacity = 7000, Calibration = new CalibrationCurve(7000d / 150d) }, + }, + Events = + [ + Delivery(30, Utc(2020, 9, 7), 3500), + TankLevelCm(30, Utc(2022, 9, 8), 35), + TankLevelCm(30, Utc(2022, 10, 14), 34), + Delivery(30, Utc(2022, 12, 5), 3000), + TankLevelCm(30, Utc(2022, 12, 5), 85), + ], + TimeZone = Berlin, + }); + + Assert.Equal(2, result.Count); + Assert.Equal([Utc(2022, 9, 8), Utc(2022, 10, 14)], result.Select(c => c.IntervalStart!.Value)); + Assert.Equal([Utc(2022, 10, 14), Utc(2022, 12, 5)], result.Select(c => c.IntervalEnd!.Value)); + Assert.Equal([Utc(2022, 10, 14), Utc(2022, 12, 5)], result.Select(c => c.Time)); + Assert.DoesNotContain(result, c => c.OpeningBalance || c.Divided || c.Gap != CoverageGapReason.None); + } + + [Fact] + public void A_dipstick_at_local_midnight_is_booked_in_the_day_it_closes() + { + var result = _engine.Normalize(new NormalizationContext + { + Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" }, + Events = + [ + new MeterEvent { MeterId = 30, Time = InBerlin(2026, 8, 15, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" }, + new MeterEvent { MeterId = 30, Time = InBerlin(2026, 9, 1), EventType = MeterEventType.TankLevel, Amount = 2800, Unit = "L" }, + ], + TimeZone = Berlin, + }); + + var row = Assert.Single(result); + Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), row.Time); + Assert.Equal(InBerlin(2026, 9, 1), row.IntervalEnd); + Assert.Equal(200, row.Amount); + } + + [Fact] + public void A_tank_nobody_drew_from_across_a_month_boundary_is_exact_in_every_month_but_a_level_that_rose_is_not() + { + MeterEvent Level(DateTimeOffset time, double litres) => + new() { MeterId = 30, Time = time, EventType = MeterEventType.TankLevel, Amount = litres, Unit = "L" }; + + var result = _engine.Normalize(new NormalizationContext + { + Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" }, + Events = [Level(InBerlin(2026, 6, 20, 10), 3000), Level(InBerlin(2026, 7, 20, 10), 3000), Level(InBerlin(2026, 8, 20, 10), 3100)], + TimeZone = Berlin, + }); + + Assert.Equal([true, false], result.Select(c => c.Divided)); + Assert.Equal([ReadingQuality.Manual, ReadingQuality.Estimated], result.Select(c => c.Quality)); + Assert.All(result, c => Assert.Equal(0, c.Amount)); + } + + // ---- Instant rate ---------------------------------------------------------------------------- + + [Fact] + public void A_silence_far_longer_than_the_sensors_rhythm_is_a_sample_gap_that_keeps_its_integral() + { + // 2 kW sampled every five minutes from 10:00 to 11:00, then nothing until 14:00, then 14:05. + var samples = Enumerable.Range(0, 13).Select(i => Measured(Utc(2024, 6, 1, 10).AddMinutes(5 * i), 2)) + .Append(Measured(Utc(2024, 6, 1, 14), 2)) + .Append(Measured(Utc(2024, 6, 1, 14, 5), 2)) + .ToList(); + + var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples)); + + var gap = Assert.Single(result, c => c.Gap != CoverageGapReason.None); + Assert.Equal(CoverageGapReason.SampleGap, gap.Gap); + Assert.Equal(ReadingQuality.Estimated, gap.Quality); + Assert.Equal(6, gap.Amount, 9); // 2 kW over three hours, still counted + Assert.Equal(Utc(2024, 6, 1, 11), gap.IntervalStart); + Assert.Equal(Utc(2024, 6, 1, 14), gap.IntervalEnd); + Assert.Equal(13, result.Count(c => c.Gap == CoverageGapReason.None && c.Quality == ReadingQuality.Measured)); + Assert.Equal(CoverageGapReason.None, result.Single(c => c.IntervalStart == Utc(2024, 6, 1, 14)).Gap); + } + + [Fact] + public void Each_sample_interval_is_a_row_interval_and_the_first_sample_only_seeds_the_integral() + { + var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, + [Measured(Utc(2024, 6, 1, 10), 0), Measured(Utc(2024, 6, 1, 11), 2), Measured(Utc(2024, 6, 1, 12), 4)])); + + Assert.Equal([Utc(2024, 6, 1, 10), Utc(2024, 6, 1, 11)], result.Select(c => c.IntervalStart!.Value)); + Assert.Equal([Utc(2024, 6, 1, 11), Utc(2024, 6, 1, 12)], result.Select(c => c.IntervalEnd!.Value)); + Assert.DoesNotContain(result, c => c.OpeningBalance || c.Gap != CoverageGapReason.None); + } + + [Theory] + [InlineData(new[] { 1, 1, 1, 1 }, 61)] // a fast sensor: ten minutes would be too eager, an hourly interval is the floor + [InlineData(new[] { 60, 60, 60 }, 600)] // hourly: a gap is longer than ten hours + [InlineData(new[] { 5, 5, 5, 180 }, 61)] // the gap itself does not raise the threshold: the median stays 5 + [InlineData(new[] { 10, 10, 20, 20 }, 150)] // an even count takes the middle of the two middle steps + public void The_sample_gap_threshold_is_ten_median_intervals_but_never_under_an_hourly_interval(int[] stepMinutes, int expectedMinutes) + { + var time = Utc(2024, 6, 1); + var samples = new List { Measured(time, 1) }; + foreach (var step in stepMinutes) + { + time = time.AddMinutes(step); + samples.Add(Measured(time, 1)); + } + + var thresholds = InstantRateNormalizer.SampleGapThresholds(samples); + + Assert.Equal(stepMinutes.Length, thresholds.Length); + Assert.All(thresholds, t => Assert.Equal(TimeSpan.FromMinutes(expectedMinutes), t)); + } + + [Fact] + public void A_single_sample_has_no_interval_to_judge() + { + Assert.Empty(InstantRateNormalizer.SampleGapThresholds([Measured(Utc(2024, 6, 1), 1)])); + } + + [Fact] + public void Hourly_polls_a_few_seconds_late_after_fast_samples_are_not_gaps() + { + // m7 #6: ten-second samples for a while, then a poller every hour, five seconds late each time. + var samples = Enumerable.Range(0, 360).Select(i => Measured(Utc(2024, 6, 1, 8).AddSeconds(10 * i), 2)).ToList(); + var last = samples[^1].Time; + samples.AddRange(Enumerable.Range(1, 30).Select(i => Measured(last.AddSeconds(3605 * i), 2))); + + var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples)); + + Assert.DoesNotContain(result, c => c.Gap != CoverageGapReason.None); + Assert.Equal(2 * (samples[^1].Time - samples[0].Time).TotalHours, result.Sum(c => c.Amount), 6); + } + + [Fact] + public void A_silence_barely_over_an_hour_between_fast_samples_is_within_the_poll_slack() + { + var samples = Enumerable.Range(0, 30).Select(i => Measured(Utc(2024, 6, 1, 8).AddSeconds(10 * i), 2)).ToList(); + var silent = samples[^1].Time; + samples.Add(Measured(silent.AddSeconds(3630), 2)); + samples.Add(Measured(silent.AddSeconds(3640), 2)); + samples.Add(Measured(silent.AddSeconds(3640 + 3662), 2)); + samples.AddRange(Enumerable.Range(1, 30).Select(i => Measured(silent.AddSeconds(3640 + 3662 + (10 * i)), 2))); + + var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples)); + + // An hour and thirty seconds is an hourly interval; an hour and a minute and two seconds is not. + var gap = Assert.Single(result, c => c.Gap == CoverageGapReason.SampleGap); + Assert.Equal(silent.AddSeconds(3640), gap.IntervalStart); + } + + [Fact] + public void An_outage_is_judged_by_the_rhythm_around_it_not_by_the_meters_whole_history() + { + // A year of hourly polls would put the whole-history threshold at ten hours and hide a five-hour + // outage of the five-minute sensor that replaced them; the rhythm around the outage finds it. + var hourly = Enumerable.Range(0, 200).Select(i => Measured(Utc(2024, 1, 1).AddHours(i), 1)); + var fastStart = Utc(2024, 1, 1).AddHours(199); + var fast = Enumerable.Range(1, 60).Select(i => Measured(fastStart.AddMinutes(5 * i), 1)).ToList(); + var resumed = fast[^1].Time.AddHours(5); + var after = Enumerable.Range(0, 60).Select(i => Measured(resumed.AddMinutes(5 * i), 1)); + + var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, [.. hourly, .. fast, .. after])); + + var gap = Assert.Single(result, c => c.Gap == CoverageGapReason.SampleGap); + Assert.Equal(fast[^1].Time, gap.IntervalStart); + Assert.Equal(resumed, gap.IntervalEnd); + } + + // ---- Direct delta ---------------------------------------------------------------------------- + + [Fact] + public void Direct_delta_rows_cover_the_time_since_the_previous_report() + { + var result = _engine.Normalize(Context(MeterMode.DirectDelta, Berlin, + [ + Manual(InBerlin(2026, 9, 1, 6), 5), + Manual(InBerlin(2026, 9, 1, 12), 3), + Manual(InBerlin(2026, 9, 2), 4), + ])); + + Assert.True(result[0].OpeningBalance); + Assert.Equal(result[0].IntervalEnd, result[0].IntervalStart); + Assert.Equal([InBerlin(2026, 9, 1, 6), InBerlin(2026, 9, 1, 12)], result.Skip(1).Select(c => c.IntervalStart!.Value)); + Assert.Equal(InBerlin(2026, 9, 1, 23, 59, 59), result[2].Time); + Assert.Equal([5d, 3d, 4d], result.Select(c => c.Amount)); + } + + [Fact] + public void A_first_direct_delta_is_an_opening_balance_even_when_the_install_date_is_known() + { + // m7 #1: the first increment covers one reporting step nobody recorded, not the years since the meter + // was installed — otherwise 0.05 kWh would claim coverage all the way back to 2020. + var first = Assert.Single(_engine.Normalize(Context( + MeterMode.DirectDelta, Berlin, [Measured(InBerlin(2026, 9, 19, 10), 0.05)], installedAt: new DateOnly(2020, 1, 1)))); + + Assert.True(first.OpeningBalance); + Assert.Equal(InBerlin(2026, 9, 19, 10), first.IntervalStart); + Assert.Equal(first.IntervalEnd, first.IntervalStart); + Assert.Empty(MeterVault.Core.Analysis.Coverage.CoverageBuilder.Build([first], Berlin)); + } + + [Fact] + public void A_register_still_starts_its_first_reading_at_the_install_date() + { + var register = Assert.Single(_engine.Normalize(Context( + MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 1, 6), 5)], installedAt: new DateOnly(2026, 8, 31)))); + + Assert.False(register.OpeningBalance); + Assert.Equal(InBerlin(2026, 8, 31), register.IntervalStart); + } + + [Fact] + public void A_direct_delta_month_row_covers_its_month_whatever_came_before_it() + { + // Behind UTC the month rows are stamped at the local month start, and still cover whole months. + var result = _engine.Normalize(Context(MeterMode.DirectDelta, NewYork, + [ + Manual(Local(NewYork, 2026, 6, 20, 12), 7), + Reading(1, Month(2026, 7), 960), + Reading(1, Month(2026, 8), 1000), + ])); + + var months = result.Where(c => c.Amount >= 960).ToList(); + Assert.Equal([Local(NewYork, 2026, 7, 1), Local(NewYork, 2026, 8, 1)], months.Select(c => c.IntervalStart!.Value)); + Assert.Equal([Local(NewYork, 2026, 8, 1), Local(NewYork, 2026, 9, 1)], months.Select(c => c.IntervalEnd!.Value)); + Assert.Equal([Local(NewYork, 2026, 7, 1), Local(NewYork, 2026, 8, 1)], months.Select(c => c.Time)); + Assert.DoesNotContain(months, c => c.OpeningBalance); + } + + // ---- Coalesce -------------------------------------------------------------------------------- + + [Fact] + public void Rows_merged_onto_one_stamp_describe_both_intervals_and_keep_every_warning() + { + var at = Utc(2026, 8, 31, 21, 59); + var merged = Assert.Single(NormalizationEngine.Coalesce( + [ + new Consumption + { + MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddHours(-2), IntervalEnd = at, Divided = true, + SourceStart = at.AddDays(-2), SourceEnd = at.AddMinutes(1), + }, + new Consumption + { + MeterId = 1, Time = at, Amount = 2, IntervalStart = at.AddHours(-5), IntervalEnd = at.AddHours(1), + SourceStart = at.AddHours(-5), SourceEnd = at.AddHours(1), OpeningBalance = true, Gap = CoverageGapReason.SampleGap, + }, + ])); + + Assert.Equal(3, merged.Amount); + Assert.Equal(at.AddHours(-5), merged.IntervalStart); + Assert.Equal(at.AddHours(1), merged.IntervalEnd); + Assert.Equal(at.AddDays(-2), merged.SourceStart); + Assert.Equal(at.AddHours(1), merged.SourceEnd); + Assert.False(merged.Divided); + Assert.True(merged.OpeningBalance); + Assert.Equal(CoverageGapReason.SampleGap, merged.Gap); + Assert.Equal(ReadingQuality.Estimated, merged.Quality); + } + + [Fact] + public void Two_divided_shares_merged_onto_one_stamp_stay_a_divided_share() + { + var at = Utc(2026, 8, 31, 21, 59); + var merged = Assert.Single(NormalizationEngine.Coalesce( + [ + new Consumption { MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddDays(-3), IntervalEnd = at.AddSeconds(1), Divided = true }, + new Consumption { MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddDays(-1), IntervalEnd = at.AddSeconds(1), Divided = true }, + ])); + + Assert.True(merged.Divided); + Assert.Equal(at.AddDays(-3), merged.IntervalStart); + } + + [Fact] + public void A_live_reading_merged_with_a_month_row_spans_the_whole_month() + { + // New York: HA polled at exactly the local midnight the "Juli" row is stamped at. The two rows share + // one key; the merged row covers July. + var julyMidnight = Local(NewYork, 2026, 7, 1); + var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, NewYork, + [ + Reading(1, Month(2026, 6), 1000), + Reading(1, Month(2026, 7), 1300), + Measured(julyMidnight, 1005), + ])); + + var july = Assert.Single(result, c => c.Time == julyMidnight); + Assert.Equal(300, july.Amount, 9); + Assert.Equal(julyMidnight, july.IntervalStart); + Assert.Equal(Local(NewYork, 2026, 8, 1), july.IntervalEnd); + } +} diff --git a/tests/Core.Tests/Analysis/FormulaParserTests.cs b/tests/Core.Tests/Analysis/FormulaParserTests.cs new file mode 100644 index 0000000..8e99be3 --- /dev/null +++ b/tests/Core.Tests/Analysis/FormulaParserTests.cs @@ -0,0 +1,163 @@ +using MeterVault.Core.Analysis.Virtual; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// The formula grammar is user input evaluated on every read, so the parser is the safety boundary (D-26): it +/// accepts only numbers, m<id>, unary minus, + - * / and parentheses, reports every rejection +/// with a position instead of throwing, and cannot be driven into a stack overflow. Ported from the old +/// ExpressionEvaluatorTests; the one deliberate change is that an unknown identifier is now an error, not 0. +/// +public sealed class FormulaParserTests +{ + private static double Evaluate(string text) => + Formula.Parse(text).Evaluate(id => id switch { 1 => 411, 2 => 416, _ => throw new KeyNotFoundException($"m{id}") }); + + [Theory] + [InlineData("1 + 2 * 3", 7)] + [InlineData("(1 + 2) * 3", 9)] + [InlineData("-5", -5)] + [InlineData("--5", 5)] + [InlineData("+5", 5)] + [InlineData("2 * -3", -6)] + [InlineData("-(1 + 2) * 3", -9)] + [InlineData("10 / 4", 2.5)] + [InlineData("2 - 3 - 4", -5)] // left-associative + [InlineData("8 / 4 / 2", 1)] // left-associative + [InlineData("0.5 * 4", 2)] + [InlineData(".5 + 5.", 5.5)] + [InlineData("m1 - m2", -5)] // Netz Einsparung Okt 2022: Haus 411 − Netz 416 + [InlineData("m1 + m2", 827)] + [InlineData("m1+m2", 827)] + [InlineData(" ( m1 ) - m2 ", -5)] + public void Evaluates_arithmetic_over_numbers_and_meter_references(string text, double expected) + { + Assert.Equal(expected, Evaluate(text), 9); + } + + [Fact] + public void Numbers_are_invariant_decimals_whatever_the_current_culture() + { + var previous = Thread.CurrentThread.CurrentCulture; + try + { + Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE"); + + Assert.Equal(0.25, Formula.Parse("0.25").Evaluate(_ => 0), 12); + Assert.Equal(FormulaErrorKind.UnexpectedCharacter, FormulaParser.Parse("0,25").Error!.Kind); + } + finally + { + Thread.CurrentThread.CurrentCulture = previous; + } + } + + [Theory] + [InlineData("1 +", FormulaErrorKind.UnexpectedEnd, 3, null)] + [InlineData("(1 + 2", FormulaErrorKind.MissingClosingParenthesis, 0, "(")] + [InlineData("m1 + (m2 * (3 - 1)", FormulaErrorKind.MissingClosingParenthesis, 5, "(")] + [InlineData("1 2", FormulaErrorKind.UnexpectedToken, 2, "2")] + [InlineData("m1 m2", FormulaErrorKind.UnexpectedToken, 3, "m2")] + [InlineData(")", FormulaErrorKind.UnexpectedToken, 0, ")")] + [InlineData("(m1))", FormulaErrorKind.UnexpectedToken, 4, ")")] + [InlineData("m1 * * m2", FormulaErrorKind.UnexpectedToken, 5, "*")] + [InlineData("1 % 2", FormulaErrorKind.UnexpectedCharacter, 2, "%")] + [InlineData("1.2.3", FormulaErrorKind.InvalidNumber, 0, "1.2.3")] + [InlineData(".", FormulaErrorKind.InvalidNumber, 0, ".")] + [InlineData("1e3", FormulaErrorKind.UnexpectedToken, 1, "e3")] + [InlineData("2m1", FormulaErrorKind.UnexpectedToken, 1, "m1")] + [InlineData("m99999999999", FormulaErrorKind.MeterIdOutOfRange, 0, "m99999999999")] + public void Rejects_malformed_formulas_with_the_position_and_token(string text, FormulaErrorKind kind, int position, string? token) + { + var result = FormulaParser.Parse(text); + + Assert.False(result.Success); + Assert.Equal(new FormulaError(kind, position, token), result.Error); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Empty_text_is_an_error_not_an_exception(string? text) + { + Assert.Equal(FormulaErrorKind.Empty, FormulaParser.Parse(text).Error!.Kind); + } + + [Theory] + [InlineData("unknown + 1", "unknown", 0)] // the old evaluator read this as 1 + [InlineData("m1 - n2", "n2", 5)] + [InlineData("M1 + m2", "M1", 0)] + [InlineData("m1a", "m1a", 0)] + [InlineData("m_1", "m_1", 0)] + [InlineData("m", "m", 0)] + [InlineData("sum(m1)", "sum", 0)] + [InlineData("m1 + Hausverbrauch", "Hausverbrauch", 5)] + public void Only_m_digits_identifiers_are_meter_references_and_anything_else_is_named(string text, string identifier, int position) + { + Assert.Equal(new FormulaError(FormulaErrorKind.UnknownIdentifier, position, identifier), FormulaParser.Parse(text).Error); + } + + [Fact] + public void Nesting_64_parentheses_deep_is_accepted_and_65_is_rejected_at_the_65th() + { + static string Nested(int depth) => new string('(', depth) + "m1" + new string(')', depth); + + Assert.True(FormulaParser.Parse(Nested(FormulaParser.MaxDepth)).Success); + + var tooDeep = FormulaParser.Parse(Nested(FormulaParser.MaxDepth + 1)); + Assert.Equal(new FormulaError(FormulaErrorKind.TooDeep, 64, "("), tooDeep.Error); + } + + [Fact] + public void Two_thousand_characters_are_accepted_and_2001_rejected() + { + var chain = "m1" + string.Concat(Enumerable.Repeat(" + m1", 399)); // 1,997 characters + var exactly = chain + " "; + Assert.Equal(FormulaParser.MaxLength, exactly.Length); + + var accepted = FormulaParser.Parse(exactly); + Assert.True(accepted.Success); + Assert.Equal(400 * 411, accepted.Formula.Evaluate(_ => 411), 6); + + Assert.Equal(FormulaErrorKind.TooLong, FormulaParser.Parse(exactly + " ").Error!.Kind); + } + + [Fact] + public void A_hundred_thousand_open_parentheses_are_rejected_without_overflowing_the_stack() + { + Assert.Equal(FormulaErrorKind.TooLong, FormulaParser.Parse(new string('(', 100_000)).Error!.Kind); + Assert.Equal(FormulaErrorKind.TooDeep, FormulaParser.Parse(new string('(', 1_999)).Error!.Kind); + } + + [Fact] + public void Long_operator_chains_and_sign_runs_parse_and_evaluate_without_recursion() + { + var signs = FormulaParser.Parse(new string('-', 1_999) + "1"); + Assert.True(signs.Success); + Assert.Equal(-1, signs.Formula.Evaluate(_ => 0)); + + var product = FormulaParser.Parse("1" + string.Concat(Enumerable.Repeat(" * 1", 499))); + Assert.True(product.Success); + Assert.Equal(1, product.Formula.Evaluate(_ => 0)); + Assert.Equal(product.Formula, Formula.Parse(product.Formula.ToString())); + } + + [Fact] + public void Scanning_finds_meter_tokens_even_in_text_that_does_not_parse() + { + Assert.Equal([2, 5, 12], FormulaParser.ScanMeterIds("m12 + (m5 * m2 +")); + Assert.Empty(FormulaParser.ScanMeterIds("1.5 + x2")); + Assert.Empty(FormulaParser.ScanMeterIds(null)); + } + + [Fact] + public void Rewriting_ids_in_text_keeps_the_users_spacing_and_even_a_syntax_error() + { + var map = new Dictionary { [1] = 41, [2] = 42 }; + + Assert.Equal("m41 -( m42 )", FormulaParser.RewriteMeterIds("m1 -( m2 )", id => map[id])); + Assert.Equal("m41 + ", FormulaParser.RewriteMeterIds("m1 + ", id => map[id])); + Assert.Equal("0.1m99 + xm1", FormulaParser.RewriteMeterIds("0.1m2 + xm1", _ => 99)); // "xm1" names no meter + } +} diff --git a/tests/Core.Tests/Analysis/FormulaTests.cs b/tests/Core.Tests/Analysis/FormulaTests.cs new file mode 100644 index 0000000..fe3901b --- /dev/null +++ b/tests/Core.Tests/Analysis/FormulaTests.cs @@ -0,0 +1,158 @@ +using MeterVault.Core.Analysis.Virtual; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// A parsed formula knows its own shape: which meters it reads, whether it is linear (additive, so buckets sum to +/// the total and the quantity can be priced) or a pure sum (so costs can be the sources' own), and a canonical text +/// that parses back to the same formula. +/// +public sealed class FormulaTests +{ + [Fact] + public void Meter_ids_are_distinct_and_ascending_and_references_keep_their_positions() + { + var formula = Formula.Parse("m12 + m3 - m12 * 2"); + + Assert.Equal([3, 12], formula.MeterIds); + Assert.Equal( + [new FormulaReference(12, 0, 3), new FormulaReference(3, 6, 2), new FormulaReference(12, 11, 3)], + formula.References); + } + + [Theory] + [InlineData("m1 + m2", true, true)] + [InlineData("(m1 + m2) * 1", true, true)] + [InlineData("m1 - m2", true, false)] + [InlineData("m1 + m1", true, false)] + [InlineData("2 * (m1 + m2)", true, false)] + [InlineData("0.5 * m1 + m2 / 4", true, false)] + [InlineData("-(m1 - m2)", true, false)] + [InlineData("m1 + 5 - 5", true, true)] // the constants cancel + [InlineData("m1 + 5", false, false)] // a constant term: not additive across buckets + [InlineData("m1 * m2", false, false)] + [InlineData("m1 / m2", false, false)] + [InlineData("1 / m1", false, false)] + [InlineData("m1 / 0", false, false)] + [InlineData("m1 * (1 / 0)", false, false)] + [InlineData("(m1 - m1) * m2", false, false)] // decided by structure, not by the value that cancels + public void Linearity_and_pure_sums_are_recognised(string text, bool linear, bool pureSum) + { + var formula = Formula.Parse(text); + + Assert.Equal(linear, formula.IsLinear); + Assert.Equal(pureSum, formula.IsPureSum); + Assert.Equal(linear, formula.Coefficients is not null); + } + + [Fact] + public void Coefficients_are_each_meters_weight_in_a_linear_formula() + { + Assert.Equal(new Dictionary { [1] = 1, [2] = -1 }, Formula.Parse("m1 - m2").Coefficients); + Assert.Equal(new Dictionary { [1] = 0.5, [2] = 0.25 }, Formula.Parse("0.5 * m1 + m2 / 4").Coefficients); + Assert.Equal(new Dictionary { [1] = -1, [2] = 1 }, Formula.Parse("-(m1 - m2)").Coefficients); + Assert.Equal(new Dictionary { [1] = 0, [2] = 1 }, Formula.Parse("m1 - m1 + m2").Coefficients); + } + + [Theory] + [InlineData("m1+m2", "m1 + m2")] + [InlineData("((((m1))))", "m1")] + [InlineData("(m1 + m2) * 2", "(m1 + m2) * 2")] + [InlineData("(m1 - m2) - m3", "m1 - m2 - m3")] + [InlineData("m1 - (m2 - m3)", "m1 - (m2 - m3)")] + [InlineData("m1 - (m2 + m3)", "m1 - (m2 + m3)")] + [InlineData("m1 + (m2 + m3)", "m1 + (m2 + m3)")] + [InlineData("m1 / (m2 * m3)", "m1 / (m2 * m3)")] + [InlineData("m1 * m2 + m3", "m1 * m2 + m3")] + [InlineData("-(m1 + m2)", "-(m1 + m2)")] + [InlineData("-m1 * m2", "-m1 * m2")] + [InlineData("--m1", "--m1")] + [InlineData("+m1", "m1")] + [InlineData("m1 * -m2", "m1 * -m2")] + [InlineData("m1 - -5", "m1 - -5")] + [InlineData("0.50 * m1", "0.50 * m1")] + public void The_canonical_text_has_minimal_parentheses_and_parses_back_to_an_equal_formula(string text, string canonical) + { + var formula = Formula.Parse(text); + + Assert.Equal(canonical, formula.ToString()); + Assert.Equal(text, formula.Text); + Assert.Equal(formula, Formula.Parse(formula.ToString())); + } + + [Fact] + public void Equality_is_structural() + { + Assert.Equal(Formula.Parse("m1+m2"), Formula.Parse("(m1) + m2")); + Assert.Equal(Formula.Parse("m1+m2").GetHashCode(), Formula.Parse("(m1) + m2").GetHashCode()); + Assert.Equal(Formula.Parse("1.0 * m1"), Formula.Parse("1 * m1")); + Assert.NotEqual(Formula.Parse("m1 + m2"), Formula.Parse("m2 + m1")); + Assert.NotEqual(Formula.Parse("m1 - m2 - m3"), Formula.Parse("m1 - (m2 - m3)")); + } + + [Fact] + public void The_tree_mirrors_precedence() + { + var root = Assert.IsType(Formula.Parse("m1 - 2 * m2").Root); + + Assert.Equal(FormulaOperator.Subtract, root.Operator); + Assert.Equal(1, Assert.IsType(root.Left).MeterId); + var product = Assert.IsType(root.Right); + Assert.Equal(FormulaOperator.Multiply, product.Operator); + Assert.Equal(2, Assert.IsType(product.Left).Value); + Assert.Equal(2, Assert.IsType(product.Right).MeterId); + } + + [Fact] + public void Division_by_zero_evaluates_to_a_non_finite_number_for_the_caller_to_judge() + { + var ratio = Formula.Parse("m1 / m2"); + + Assert.True(double.IsPositiveInfinity(ratio.Evaluate(id => id == 1 ? 80 : 0))); + Assert.True(double.IsNaN(ratio.Evaluate(_ => 0))); + } + + [Fact] + public void Rewriting_ids_follows_meters_to_new_ids_and_keeps_the_users_text() + { + var map = new Dictionary { [1] = 41, [2] = 42 }; + var formula = Formula.Parse("m1 + (m2*m1)"); + + var rewritten = formula.RewriteIds(id => map[id]); + + Assert.Equal("m41 + (m42*m41)", rewritten.Text); + Assert.Equal([41, 42], rewritten.MeterIds); + Assert.Equal(Formula.Parse("m41 + m42 * m41"), rewritten); + Assert.Equal( + [new FormulaReference(41, 0, 3), new FormulaReference(42, 8, 3), new FormulaReference(41, 12, 3)], + rewritten.References); + Assert.Equal(Formula.Parse(rewritten.Text), rewritten); + } + + [Fact] + public void Rewriting_to_a_negative_id_is_refused() + { + Assert.Throws(() => Formula.Parse("m1").RewriteIds(_ => -1)); + } + + [Fact] + public void Sum_and_difference_build_the_editors_simple_modes() + { + var sum = Formula.Sum([4, 5, 4]); + Assert.Equal("m4 + m5", sum.ToString()); + Assert.True(sum.IsPureSum); + + var difference = Formula.Difference(1, [2, 3]); + Assert.Equal("m1 - m2 - m3", difference.ToString()); + Assert.Equal(new Dictionary { [1] = 1, [2] = -1, [3] = -1 }, difference.Coefficients); + + Assert.Throws(() => Formula.Sum([])); + Assert.Throws(() => Formula.Difference(1, [])); + } + + [Fact] + public void Parse_throws_only_for_trusted_text_that_turns_out_invalid() + { + Assert.Throws(() => Formula.Parse("m1 +")); + } +} diff --git a/tests/Core.Tests/Analysis/FreshnessRulesTests.cs b/tests/Core.Tests/Analysis/FreshnessRulesTests.cs new file mode 100644 index 0000000..4ae6dca --- /dev/null +++ b/tests/Core.Tests/Analysis/FreshnessRulesTests.cs @@ -0,0 +1,106 @@ +using MeterVault.Core.Analysis; + +namespace MeterVault.Core.Tests.Analysis; + +/// D-18: the freshness mark is the last reading or event, and only a live source can be stale. +public sealed class FreshnessRulesTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 19, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void Without_any_reading_or_event_there_is_no_data() + { + var freshness = FreshnessRules.Evaluate(new FreshnessInput(null, null, [], HasLiveSource: true, null), Now); + + Assert.Equal(FreshnessState.NoData, freshness.State); + Assert.Null(freshness.LastActivity); + } + + [Fact] + public void An_import_only_meter_is_historical_never_stale() + { + var years = Now.AddYears(-3); + + var freshness = FreshnessRules.Evaluate(new FreshnessInput(years, null, [years], HasLiveSource: false, null), Now); + + Assert.Equal(FreshnessState.Historical, freshness.State); + Assert.Equal(years, freshness.LastActivity); + } + + [Fact] + public void A_live_source_is_stale_after_three_of_its_own_intervals() + { + // Readings every 10 minutes; the last one 40 minutes ago is more than 3 × 10 minutes. + var times = Enumerable.Range(0, 21).Select(i => Now.AddMinutes(-40 - (10 * i))).ToList(); + + var stale = FreshnessRules.Evaluate(new FreshnessInput(times[0], null, times, HasLiveSource: true, null), Now); + var fresh = FreshnessRules.Evaluate(new FreshnessInput(times[0], null, times, HasLiveSource: true, null), times[0].AddMinutes(25)); + + Assert.Equal(FreshnessState.Stale, stale.State); + Assert.Equal(TimeSpan.FromMinutes(30), stale.StaleAfter); + Assert.Equal(FreshnessState.Live, fresh.State); + } + + [Fact] + public void The_poll_interval_keeps_a_rarely_polled_source_live_between_polls() + { + // Two readings a minute apart, but the source is polled hourly: 3 × 60 min wins over 3 × 1 min. + var last = Now.AddMinutes(-90); + var times = new[] { last, last.AddMinutes(-1) }; + + var freshness = FreshnessRules.Evaluate(new FreshnessInput(last, null, times, HasLiveSource: true, TimeSpan.FromHours(1)), Now); + + Assert.Equal(FreshnessState.Live, freshness.State); + Assert.Equal(TimeSpan.FromHours(3), freshness.StaleAfter); + } + + [Fact] + public void An_event_after_the_last_reading_is_the_mark() + { + var reading = Now.AddDays(-10); + var delivery = Now.AddHours(-1); + + var freshness = FreshnessRules.Evaluate(new FreshnessInput(reading, delivery, [reading], HasLiveSource: false, null), Now); + + Assert.Equal(delivery, freshness.LastActivity); + } + + [Fact] + public void A_live_source_with_an_unknown_rhythm_is_not_called_stale() + { + var once = Now.AddDays(-30); + + var freshness = FreshnessRules.Evaluate(new FreshnessInput(once, null, [once], HasLiveSource: true, null), Now); + + Assert.Equal(FreshnessState.Live, freshness.State); + Assert.Null(freshness.StaleAfter); + } + + [Fact] + public void The_median_uses_the_latest_twenty_intervals() + { + // 30 readings: the oldest ten are a day apart, the latest 21 an hour apart. + var times = Enumerable.Range(0, 21).Select(i => Now.AddHours(-i)) + .Concat(Enumerable.Range(1, 9).Select(i => Now.AddHours(-20).AddDays(-i))) + .ToList(); + + Assert.Equal(TimeSpan.FromHours(1), FreshnessRules.MedianInterval(times)); + Assert.Null(FreshnessRules.MedianInterval([Now])); + } + + [Fact] + public void Combined_freshness_is_as_current_as_its_least_current_source() + { + var combined = FreshnessRules.Combine( + [ + new Freshness(FreshnessState.Live, Now.AddMinutes(-5)), + new Freshness(FreshnessState.Stale, Now.AddDays(-2)), + Freshness.None, + ]); + + Assert.Equal(FreshnessState.Stale, combined.State); + Assert.Equal(Now.AddDays(-2), combined.LastActivity); + Assert.Equal(FreshnessState.Historical, FreshnessRules.Combine([new Freshness(FreshnessState.Historical, Now)]).State); + Assert.Equal(FreshnessState.NoData, FreshnessRules.Combine([]).State); + } +} diff --git a/tests/Core.Tests/Analysis/LegacyPeriodsTests.cs b/tests/Core.Tests/Analysis/LegacyPeriodsTests.cs new file mode 100644 index 0000000..82ef38b --- /dev/null +++ b/tests/Core.Tests/Analysis/LegacyPeriodsTests.cs @@ -0,0 +1,166 @@ +using MeterVault.Core.Analysis; +using static MeterVault.Core.Tests.Analysis.AnalysisClock; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// The legacy entry points' ranges (D-45): the REST API's exact instants and the older services' date ranges become +/// ordinary resolved periods, cut at now, that the bucket planner tiles exactly. +/// +public sealed class LegacyPeriodsTests +{ + private static readonly DateTimeOffset Now = At(Berlin, 2026, 9, 19, 14, 37); + + [Fact] + public void Utc_midnights_keep_their_exact_instants_in_a_zone_ahead_of_utc() + { + // The contract test's bounds: 2024-01-01T00:00Z is 01:00 in Berlin, and 2024-03-01T00:00Z is 01:00 on 1 March. + var period = LegacyPeriods.FromInstants(Utc(2024, 1, 1), Utc(2024, 3, 1), Now, Berlin); + + Assert.Equal((Day(2024, 1, 1), Day(2024, 3, 1)), (period.FirstDay, period.LastDay)); + Assert.Equal((Utc(2024, 1, 1), Utc(2024, 3, 1)), (period.From, period.To)); + Assert.False(period.IsToDate); + Assert.False(period.HasNotStarted()); + + var plan = BucketPlanner.Plan(period, BucketSize.Month); + + // January from 01:00, February whole, and the first hour of 1 March — the buckets tile [from, to) exactly. + Assert.Equal(3, plan.Buckets.Count); + Assert.Equal(Utc(2024, 1, 1), plan.Buckets[0].From); + Assert.Equal(At(Berlin, 2024, 2, 1), plan.Buckets[0].To); + Assert.Equal((At(Berlin, 2024, 3, 1), Utc(2024, 3, 1)), (plan.Buckets[2].From, plan.Buckets[2].To)); + Assert.Equal([Day(2024, 1, 1), Day(2024, 2, 1), Day(2024, 3, 1)], plan.Buckets.Select(LegacyPeriods.KeyOf)); + } + + [Fact] + public void Local_midnights_give_whole_months() + { + var period = LegacyPeriods.FromInstants(At(Berlin, 2024, 1, 1), At(Berlin, 2024, 3, 1), Now, Berlin); + + Assert.Equal((Day(2024, 1, 1), Day(2024, 2, 29)), (period.FirstDay, period.LastDay)); + var plan = BucketPlanner.Plan(period, BucketSize.Month); + Assert.Equal(2, plan.Buckets.Count); + Assert.Equal(period, PeriodResolver.Resolve(PeriodPreset.Custom, Day(2024, 1, 1), Day(2024, 2, 29), Now, Berlin)); + } + + [Fact] + public void An_offset_is_read_as_the_instant_it_names() + { + var withOffset = LegacyPeriods.FromInstants( + new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)), new DateTimeOffset(2024, 3, 1, 0, 0, 0, TimeSpan.FromHours(1)), Now, Berlin); + + Assert.Equal(TimeSpan.Zero, withOffset.From.Offset); + Assert.Equal(At(Berlin, 2024, 1, 1), withOffset.From); + Assert.Equal(At(Berlin, 2024, 3, 1), withOffset.To); + } + + [Fact] + public void A_range_reaching_past_now_stops_at_now() + { + // D-04: a legacy caller asking for this month and the next still gets actuals up to now only. + var period = LegacyPeriods.FromInstants(At(Berlin, 2026, 9, 1), At(Berlin, 2026, 11, 1), Now, Berlin); + + Assert.True(period.IsToDate); + Assert.True(period.ExtendsPastNow); + Assert.Equal(Now, period.To); + Assert.Equal(Day(2026, 10, 31), period.LastDay); + + var plan = BucketPlanner.Plan(period, BucketSize.Month); + var bucket = Assert.Single(plan.Buckets); + Assert.Equal(Now, bucket.To); + } + + [Fact] + public void A_range_after_now_has_not_started_and_plans_nothing() + { + var period = LegacyPeriods.FromInstants(At(Berlin, 2026, 10, 1), At(Berlin, 2026, 11, 1), Now, Berlin); + + Assert.True(period.HasNotStarted()); + Assert.Equal(period.From, period.To); + Assert.Empty(BucketPlanner.Plan(period, BucketSize.Month).Buckets); + Assert.Empty(LegacyPeriods.WholePeriodPlan(period).Buckets); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void An_empty_or_inverted_range_plans_nothing(int days) + { + var from = At(Berlin, 2024, 5, 1); + var period = LegacyPeriods.FromInstants(from, from.AddDays(days), Now, Berlin); + + Assert.True(period.HasNoHistory()); + Assert.Empty(BucketPlanner.Plan(period, BucketSize.Month).Buckets); + } + + [Fact] + public void Instants_outside_the_supported_dates_are_clamped() + { + var period = LegacyPeriods.FromInstants(DateTimeOffset.MinValue, DateTimeOffset.MaxValue, Now, Berlin); + + Assert.Equal(PeriodResolver.MinSupportedDate, period.FirstDay); + Assert.Equal(At(Berlin, 1900, 1, 1), period.From); + Assert.Equal(Now, period.To); + Assert.Equal(PeriodResolver.MaxSupportedDate, period.LastDay); + Assert.False(BucketPlanner.Plan(period, BucketSize.Year).Refused); + } + + [Fact] + public void A_date_range_ends_the_day_before_its_exclusive_end_and_is_cut_at_now() + { + // The dashboard's "this year": [1 January, 1 January next year). + var year = LegacyPeriods.FromDates(Day(2026, 1, 1), Day(2027, 1, 1), Now, Berlin); + + Assert.Equal((Day(2026, 1, 1), Day(2026, 12, 31)), (year.FirstDay, year.LastDay)); + Assert.Equal(At(Berlin, 2026, 1, 1), year.From); + Assert.Equal(Now, year.To); + Assert.Equal(BucketSize.Year, PeriodBucket.Of(year).Size); + + var previous = LegacyPeriods.FromDates(Day(2025, 1, 1), Day(2026, 1, 1), Now, Berlin); + Assert.False(previous.IsToDate); + Assert.Equal(At(Berlin, 2026, 1, 1), previous.To); + } + + [Fact] + public void A_date_range_in_a_zone_behind_utc_starts_at_local_midnight() + { + var period = LegacyPeriods.FromDates(Day(2026, 7, 1), Day(2026, 8, 1), Now, NewYork); + + Assert.Equal(Utc(2026, 7, 1, 4), period.From); + Assert.Equal(Utc(2026, 8, 1, 4), period.To); + } + + [Fact] + public void An_empty_date_range_or_one_outside_the_supported_dates_is_handled() + { + Assert.True(LegacyPeriods.FromDates(Day(2026, 5, 1), Day(2026, 5, 1), Now, Berlin).HasNoHistory()); + Assert.True(LegacyPeriods.FromDates(Day(2026, 5, 2), Day(2026, 5, 1), Now, Berlin).HasNoHistory()); + + var wide = LegacyPeriods.FromDates(new DateOnly(1, 1, 1), new DateOnly(9999, 1, 1), Now, Berlin); + Assert.Equal((PeriodResolver.MinSupportedDate, PeriodResolver.MaxSupportedDate), (wide.FirstDay, wide.LastDay)); + } + + [Fact] + public void The_whole_period_plan_is_the_period_as_one_bucket() + { + var period = LegacyPeriods.FromDates(Day(2025, 1, 1), Day(2026, 1, 1), Now, Berlin); + + var plan = LegacyPeriods.WholePeriodPlan(period); + + Assert.False(plan.Refused); + Assert.Equal(BucketSize.Year, plan.Size); + Assert.Equal(PeriodBucket.Of(period), Assert.Single(plan.Buckets)); + } + + [Theory] + [InlineData(BucketSize.Day, "2026-09-17")] + [InlineData(BucketSize.Week, "2026-09-14")] + [InlineData(BucketSize.Month, "2026-09-01")] + [InlineData(BucketSize.Year, "2026-01-01")] + public void A_bucket_is_filed_under_the_start_of_its_calendar_unit(BucketSize size, string key) + { + var bucket = new AnalysisBucket(Day(2026, 9, 17), Day(2026, 9, 18), At(Berlin, 2026, 9, 17), At(Berlin, 2026, 9, 18), size); + + Assert.Equal(Iso(key), LegacyPeriods.KeyOf(bucket)); + } +} diff --git a/tests/Core.Tests/Analysis/LegacyVirtualDerivationTests.cs b/tests/Core.Tests/Analysis/LegacyVirtualDerivationTests.cs new file mode 100644 index 0000000..fb8371c --- /dev/null +++ b/tests/Core.Tests/Analysis/LegacyVirtualDerivationTests.cs @@ -0,0 +1,250 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; +using static MeterVault.Core.Tests.Analysis.VirtualFixtures; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// Expression-less virtual meters get the explicit sum their incoming links implied, when that sum is +/// unambiguous — one unit, one kind, same energy type — and are flagged "needs configuration" otherwise (D-28). +/// The seeded Summe Solar is the reference case. +/// +public sealed class LegacyVirtualDerivationTests +{ + [Fact] + public void Seeded_summe_solar_becomes_solar_1_plus_solar_2_generation_in_kWh() + { + var result = LegacyVirtualDerivation.Derive(6, SeededLinks(), new MeterCatalog(SeededElectricity())); + + Assert.Equal(LegacyDerivationOutcome.Derived, result.Outcome); + // A generation sum is not costed (A-15): generation is never billed, so its sources have no metered cost. + Assert.Equal(new VirtualDefinition("m4 + m5", QuantityKind.Generation, "kWh", VirtualCostRule.None), result.Definition); + Assert.Equal([4, 5], result.MeterIds); + Assert.Empty(result.IgnoredMeterIds); + } + + [Fact] + public void Hundreds_of_incoming_links_are_a_finding_not_an_exception() + { + // Review virtual F3: 300 links into one sum render as "m1000 + … + m1299", longer than a formula may be. That is + // this meter's configuration problem; it must never throw out of the derivation and fail every other meter. + var catalog = new MeterCatalog( + [.. Enumerable.Range(1000, 300).Select(id => Physical(id, $"PV {id}", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh")), + new CatalogMeter(5000, "Sum", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1)]); + MeterLink[] links = [.. Enumerable.Range(1000, 300).Select(id => Link(id, 5000))]; + + var run = LegacyVirtualDerivation.DeriveAll([5000], links, catalog); + + var result = Assert.Single(run.Results); + Assert.Equal(LegacyDerivationOutcome.Invalid, result.Outcome); + Assert.True(result.NeedsConfiguration); + Assert.Equal(["Syntax"], result.Values); + Assert.Null(result.Definition); + + // 250 sources still fit, and are derived. + var fewer = LegacyVirtualDerivation.Derive(5000, links[..250], catalog); + Assert.Equal(LegacyDerivationOutcome.Derived, fewer.Outcome); + Assert.Equal(250, fewer.Definition!.ReferencedMeterIds.Count); + } + + [Fact] + public void Its_outgoing_topology_link_is_not_part_of_the_calculation() + { + // Summe Solar → Haus is flow topology; Haus gets no calculation from it, and Summe none from Netz → Haus. + var catalog = new MeterCatalog(SeededElectricity()); + + var derived = LegacyVirtualDerivation.Derive(6, SeededLinks(), catalog).Definition!; + + Assert.DoesNotContain(1, derived.ReferencedMeterIds); + Assert.True(VirtualValidator.Validate(derived, 6, catalog).IsValid); + } + + [Fact] + public void Rerunning_the_derivation_changes_nothing() + { + var catalog = new MeterCatalog(SeededElectricity()); + var first = LegacyVirtualDerivation.Derive(6, SeededLinks(), catalog).Definition!; + var converted = catalog.With(catalog.Find(6)! with { Definition = first }); + + var second = LegacyVirtualDerivation.Derive(6, SeededLinks(), converted); + var rerun = LegacyVirtualDerivation.DeriveAll([6], SeededLinks(), converted); + + Assert.Equal(LegacyDerivationOutcome.AlreadyDefined, second.Outcome); + Assert.Null(second.Definition); + Assert.Equal((0, 0, 1), (rerun.Converted, rerun.NeedsConfiguration, rerun.Unchanged)); + } + + [Fact] + public void An_explicit_definition_is_never_replaced_by_one_derived_from_links() + { + // Summe Solar deliberately defined as Solar 1 only: its two incoming links must not turn it back into a sum. + var catalog = new MeterCatalog(SeededElectricity()); + var explicitOnly = catalog.With(catalog.Find(6)! with { Definition = new VirtualDefinition("m4", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts) }); + + var result = LegacyVirtualDerivation.Derive(6, SeededLinks(), explicitOnly); + + Assert.Equal(LegacyDerivationOutcome.AlreadyDefined, result.Outcome); + Assert.Null(result.Definition); + Assert.False(result.NeedsConfiguration); + } + + [Fact] + public void A_water_sum_is_written_in_the_canonical_unit() + { + var catalog = new MeterCatalog( + [ + Physical(60, "Wasser Haus", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m3", energyType: 2), + Physical(61, "Wasser Garten", MeterMode.CumulativeCounter, QuantityKind.Consumption, "M³", energyType: 2), + new CatalogMeter(62, "Wasser gesamt", MeterMode.Virtual, QuantityKind.Consumption, "m3", 2), + ]); + + var result = LegacyVirtualDerivation.Derive(62, [Link(60, 62), Link(61, 62)], catalog); + + Assert.Equal(LegacyDerivationOutcome.Derived, result.Outcome); + Assert.Equal("m³", result.Definition!.ResultUnit); + } + + [Fact] + public void Runtime_sources_need_configuration_because_runtime_is_no_virtual_result_kind() + { + var catalog = new MeterCatalog( + [ + Physical(70, "Brenner 1", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3), + Physical(71, "Brenner 2", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3), + new CatalogMeter(72, "Brenner gesamt", MeterMode.Virtual, QuantityKind.Consumption, "h", 3), + ]); + + var result = LegacyVirtualDerivation.Derive(72, [Link(70, 72), Link(71, 72)], catalog); + + Assert.Equal(LegacyDerivationOutcome.UnsupportedKind, result.Outcome); + Assert.Equal(["runtime"], result.Values); + Assert.True(result.NeedsConfiguration); + } + + [Fact] + public void Sources_of_different_units_need_configuration_and_are_named() + { + // Heating oil: the tank measures litres, the burner hours — their "sum" means nothing. + var catalog = new MeterCatalog( + [ + Physical(30, "Öltank", MeterMode.ConsumableBalance, QuantityKind.Consumption, "L", energyType: 3), + Physical(31, "Brenner", MeterMode.RuntimeCounter, QuantityKind.Consumption, "h", energyType: 3), + new CatalogMeter(32, "Heizung gesamt", MeterMode.Virtual, QuantityKind.Consumption, "L", 3), + ]); + + var result = LegacyVirtualDerivation.Derive(32, [Link(30, 32), Link(31, 32)], catalog); + + Assert.Equal(LegacyDerivationOutcome.MixedUnits, result.Outcome); + Assert.Null(result.Definition); + Assert.Equal([30, 31], result.MeterIds); + Assert.Equal(["L", "h"], result.Values); + } + + [Fact] + public void Consumption_plus_generation_is_ambiguous_and_needs_configuration() + { + var catalog = new MeterCatalog(SeededElectricity()); + + var result = LegacyVirtualDerivation.Derive(6, [Link(1, 6), Link(4, 6)], catalog); + + Assert.Equal(LegacyDerivationOutcome.MixedKinds, result.Outcome); + Assert.Equal(["consumption", "generation"], result.Values); + } + + [Fact] + public void Links_from_another_energy_type_are_ignored_not_summed() + { + var catalog = new MeterCatalog(SeededElectricity()); + + var result = LegacyVirtualDerivation.Derive(6, [.. SeededLinks(), Link(7, 6)], catalog); + + Assert.Equal("m4 + m5", result.Definition!.Expression); + Assert.Equal([7], result.IgnoredMeterIds); + } + + [Fact] + public void A_virtual_meter_without_incoming_links_needs_configuration() + { + var result = LegacyVirtualDerivation.Derive(6, [Link(6, 1)], new MeterCatalog(SeededElectricity())); + + Assert.Equal(LegacyDerivationOutcome.NoSources, result.Outcome); + } + + [Fact] + public void A_physical_meter_is_not_derived() + { + Assert.Equal(LegacyDerivationOutcome.NotVirtual, LegacyVirtualDerivation.Derive(1, SeededLinks(), new MeterCatalog(SeededElectricity())).Outcome); + } + + [Fact] + public void Nested_legacy_meters_are_converted_in_dependency_order() + { + // 40 = links from Solar 1 and Solar 2; 41 = links from 40 and a third generation meter 42. + var catalog = new MeterCatalog( + [ + .. SeededElectricity(), + new CatalogMeter(40, "PV Dach", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1), + new CatalogMeter(41, "PV gesamt", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1), + Physical(42, "Balkonkraftwerk", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh"), + ]); + MeterLink[] links = [Link(4, 40), Link(5, 40), Link(40, 41), Link(42, 41)]; + + // Alone, 41 has to wait for 40. + Assert.Equal(LegacyDerivationOutcome.SourceNeedsConfiguration, LegacyVirtualDerivation.Derive(41, links, catalog).Outcome); + + var run = LegacyVirtualDerivation.DeriveAll([41, 40], links, catalog); + + Assert.Equal([40, 41], run.Results.Select(r => r.MeterId)); + Assert.Equal("m40 + m42", run.Results[1].Definition!.Expression); + Assert.Equal(2, run.Converted); + Assert.Equal(0, run.NeedsConfiguration); + } + + [Fact] + public void A_self_link_does_not_make_a_legacy_meter_a_loop() + { + var run = LegacyVirtualDerivation.DeriveAll([6], [.. SeededLinks(), Link(6, 6)], new MeterCatalog(SeededElectricity())); + + var summe = Assert.Single(run.Results); + Assert.Equal(LegacyDerivationOutcome.Derived, summe.Outcome); + Assert.Equal("m4 + m5", summe.Definition!.Expression); + } + + [Fact] + public void Links_across_energy_types_neither_order_nor_loop_the_run() + { + // A flow drawing links the electricity sum and a water sum both ways. Derive ignores those links, so the run + // must not report them as a loop either. + var catalog = new MeterCatalog( + [ + .. SeededElectricity(), + Physical(60, "Wasser Haus", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m³", energyType: 2), + new CatalogMeter(62, "Wasser gesamt", MeterMode.Virtual, QuantityKind.Consumption, "m³", 2), + ]); + + var run = LegacyVirtualDerivation.DeriveAll([6, 62], [.. SeededLinks(), Link(60, 62), Link(6, 62), Link(62, 6)], catalog); + + Assert.Equal(2, run.Converted); + Assert.Equal("m60", run.Results.Single(r => r.MeterId == 62).Definition!.Expression); + } + + [Fact] + public void Legacy_meters_linked_in_a_loop_are_reported_with_the_path() + { + var catalog = new MeterCatalog( + [ + .. SeededElectricity(), + new CatalogMeter(50, "X", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1), + new CatalogMeter(51, "Y", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1), + ]); + + var run = LegacyVirtualDerivation.DeriveAll([50, 51, 6], [.. SeededLinks(), Link(50, 51), Link(51, 50), Link(4, 50)], catalog); + + Assert.Equal(1, run.Converted); + Assert.Equal(2, run.NeedsConfiguration); + var x = run.Results.Single(r => r.MeterId == 50); + Assert.Equal(LegacyDerivationOutcome.Cycle, x.Outcome); + Assert.Equal([50, 51, 50], x.MeterIds); + } +} diff --git a/tests/Core.Tests/Analysis/MatchedCoverageTests.cs b/tests/Core.Tests/Analysis/MatchedCoverageTests.cs new file mode 100644 index 0000000..04dfdfe --- /dev/null +++ b/tests/Core.Tests/Analysis/MatchedCoverageTests.cs @@ -0,0 +1,484 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using static MeterVault.Core.Tests.Analysis.CoverageTestData; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// A change figure is confident only over the time both periods cover (D-07). What these pin down: the +/// match follows the current data's actual end rather than the period's; it trims to whole months where +/// either side only has monthly data, and to interval edges inside undivided intervals; a bound on a reading +/// instant keeps the row closing there on the side its time lies on; every source of a combined scope has to +/// agree; holes split the match; an opening balance stays out of it; and no overlap means "not comparable" +/// rather than a percentage against nothing. +/// +public sealed class MatchedCoverageTests +{ + private static readonly DateTimeOffset Now = At(2026, 9, 19, 10); + + private static readonly TimeSpan Past = MatchedCoverage.PastBookedRow; + + private static DateTimeOffset PreviousYear(DateTimeOffset instant) => ShiftYears(instant, -1); + + private static ResolvedPeriod YearToDate(DateTimeOffset now) => + Period(PeriodPreset.YearToDate, new DateOnly(now.Year, 1, 1), DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, Berlin).DateTime), now); + + private static MatchedCoverageResult MatchYearToDate( + DateTimeOffset now, + CoverageRun[] currentRuns, + CoverageRun[] comparisonRuns, + DateTimeOffset[]? currentOpeningBalances = null, + DateTimeOffset[]? comparisonOpeningBalances = null) + { + var current = YearToDate(now); + return MatchedCoverage.Match( + MatchSide.Of(current.From, current.To, now, Berlin, currentRuns, currentOpeningBalances), + MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), now, Berlin, comparisonRuns, comparisonOpeningBalances), + PreviousYear); + } + + [Fact] + public void Year_to_date_with_data_until_31_May_matches_January_to_May_in_both_years() + { + var result = MatchYearToDate(Now, [MonthLabels(2026, 1, 2026, 6)], [MonthLabels(2025, 1, 2026, 1)]); + + Assert.True(result.IsComparable); + Assert.True(result.IsContiguous); + Assert.Equal(new MatchedRange(At(2026, 1, 1), At(2026, 6, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 5, 31)), result.Current); + Assert.Equal(new MatchedRange(At(2025, 1, 1), At(2025, 6, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 5, 31)), result.Comparison); + } + + [Fact] + public void Hourly_data_on_both_sides_matches_up_to_the_cut_at_now() + { + var result = MatchYearToDate( + Now, + [Run(At(2025, 6, 1), At(2026, 12, 1), ResolutionClass.Hour)], + [Run(At(2024, 6, 1), At(2025, 12, 1), ResolutionClass.Hour)]); + + Assert.Equal(At(2026, 1, 1), result.Current!.From); + Assert.Equal(Now, result.Current.To); + Assert.Equal(new DateOnly(2026, 9, 19), result.Current.LastDay); + Assert.Equal(At(2025, 9, 19, 10), result.Comparison!.To); + } + + [Fact] + public void Monthly_data_on_the_comparison_side_trims_a_mid_month_cut_to_whole_months_on_both_sides() + { + // Month-resolution data cannot say how much of May had accrued by the 19th. + var now = At(2026, 5, 19, 14, 37); + + var result = MatchYearToDate( + now, + [Run(At(2025, 12, 1), At(2026, 5, 19, 14, 37), ResolutionClass.Hour)], + [MonthLabels(2025, 1, 2026, 1)]); + + Assert.Equal(new MatchedRange(At(2026, 1, 1), At(2026, 5, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 30)), result.Current); + Assert.Equal(new MatchedRange(At(2025, 1, 1), At(2025, 5, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 30)), result.Comparison); + } + + [Fact] + public void Daily_readings_match_up_to_the_last_reading_before_now_and_include_its_row() + { + // Read at 06:00 daily; the reading of 20 September is already stamped. The interval containing now is + // given up (A-04), and the match ends on today's 06:00 reading, whose row closes the day before it. + var result = MatchYearToDate( + Now, + [Run(At(2025, 12, 31, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))], + [Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]); + + Assert.Equal(At(2026, 1, 1), result.Current!.From); + Assert.Equal(At(2026, 9, 19, 6) + Past, result.Current.To); + Assert.Equal(new DateOnly(2026, 9, 19), result.Current.LastDay); + Assert.Equal(At(2025, 9, 19, 6), result.Comparison!.To); + } + + [Fact] + public void A_cut_inside_daily_readings_falls_back_to_local_midnight() + { + // The comparison year was read at 06:00 daily; 10:00 on 19 September lies inside one of its days. + var result = MatchYearToDate( + Now, + [Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)], + [Run(At(2024, 12, 31, 6), At(2025, 12, 1, 6), ResolutionClass.Day, divided: true)]); + + Assert.Equal(At(2026, 9, 19), result.Current!.To); + Assert.Equal(new DateOnly(2026, 9, 18), result.Current.LastDay); + Assert.Equal(At(2025, 9, 19), result.Comparison!.To); + } + + [Fact] + public void An_undivided_tank_interval_open_at_the_cut_is_left_out_of_the_match() + { + var result = MatchYearToDate( + Now, + [Run(At(2026, 1, 1), At(2026, 8, 5), ResolutionClass.Month), Single(At(2026, 8, 5), At(2026, 10, 5), ResolutionClass.Coarse)], + [Run(At(2025, 1, 1), At(2025, 12, 1), ResolutionClass.Hour)]); + + Assert.Equal(At(2026, 8, 5), result.Current!.To); + Assert.Equal(At(2025, 8, 5), result.Comparison!.To); + } + + [Fact] + public void Trimming_month_data_never_reaches_back_into_the_long_interval_before_it() + { + // Monthly tank readings resumed on 3 May after a 44-day interval. Floored to 1 May, the cut would + // land inside that interval; the reading on 3 May is the last edge the data can end on. + var now = At(2026, 5, 19, 14); + + var result = MatchYearToDate( + now, + [ + Run(At(2026, 1, 1), At(2026, 3, 20), ResolutionClass.Month), + Run(At(2026, 3, 20), At(2026, 5, 3), ResolutionClass.Coarse), + Run(At(2026, 5, 3), At(2026, 6, 2), ResolutionClass.Month), + ], + [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]); + + Assert.Equal(At(2026, 5, 3), result.Current!.To); + Assert.Equal(At(2025, 5, 3), result.Comparison!.To); + } + + [Fact] + public void A_match_ending_on_a_dipstick_reading_includes_the_row_booked_at_that_reading() + { + // m2 #3 (P4): the row for 20 March → 3 May is stamped at 3 May 10:00. A query ending exactly there + // would leave those 44 days out of the current side while the comparison year keeps them. + var now = At(2026, 5, 19, 14); + + var result = MatchYearToDate( + now, + [ + Run(At(2026, 1, 1), At(2026, 3, 20, 10), ResolutionClass.Month), + Single(At(2026, 3, 20, 10), At(2026, 5, 3, 10), ResolutionClass.Coarse), + Single(At(2026, 5, 3, 10), At(2026, 6, 2, 10), ResolutionClass.Month), + ], + [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]); + + Assert.Equal(At(2026, 5, 3, 10) + Past, result.Current!.To); + Assert.Equal(new DateOnly(2026, 5, 3), result.Current.LastDay); + Assert.Equal(At(2025, 5, 3, 10), result.Comparison!.To); + } + + [Fact] + public void A_comparison_month_cut_inside_undivided_dipstick_intervals_is_cut_at_the_dipsticks() + { + // m2 #3 (P7): November 2026 hourly against November 2025, read on 20 October, 18 and 29 November and + // 20 December. 1 November and 1 December lie inside undivided intervals; their dipsticks are the only + // cut points, and the start moves past the row closing at the first one. + var current = Period(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), At(2026, 12, 5)); + var comparisonRuns = new[] + { + Single(At(2025, 10, 20, 10), At(2025, 11, 18, 10), ResolutionClass.Month), + Single(At(2025, 11, 18, 10), At(2025, 11, 29, 10), ResolutionClass.Month, divided: true), + Single(At(2025, 11, 29, 10), At(2025, 12, 20, 10), ResolutionClass.Month), + }; + + var result = MatchedCoverage.Match( + MatchSide.Of(current.From, current.To, current.Now, Berlin, [Run(At(2026, 10, 1), At(2027, 1, 1), ResolutionClass.Hour)]), + MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), current.Now, Berlin, comparisonRuns), + PreviousYear); + + Assert.Equal(new MatchedRange(At(2025, 11, 18, 10) + Past, At(2025, 11, 29, 10) + Past, new DateOnly(2025, 11, 18), new DateOnly(2025, 11, 29)), result.Comparison); + Assert.Equal(new MatchedRange(At(2026, 11, 18, 10), At(2026, 11, 29, 10), new DateOnly(2026, 11, 18), new DateOnly(2026, 11, 29)), result.Current); + } + + [Fact] + public void A_comparison_month_that_one_undivided_interval_straddles_on_both_ends_is_not_comparable() + { + // m2 #3 (P7): 14 October → 28 November 10:00 → 20 December. No dipstick falls where November can be cut. + var current = Period(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), At(2026, 12, 5)); + + var result = MatchedCoverage.Match( + MatchSide.Of(current.From, current.To, current.Now, Berlin, [Run(At(2026, 10, 1), At(2027, 1, 1), ResolutionClass.Hour)]), + MatchSide.Of( + PreviousYear(current.From), + PreviousYear(current.To), + current.Now, + Berlin, + [Single(At(2025, 10, 14), At(2025, 11, 28, 10), ResolutionClass.Coarse), Single(At(2025, 11, 28, 10), At(2025, 12, 20), ResolutionClass.Month)]), + PreviousYear); + + Assert.False(result.IsComparable); + } + + [Fact] + public void A_combined_scope_starts_its_match_where_every_source_can_be_cut() + { + // m2 #4 (P1): source A books 1 January – 15 March in one interval, then reads daily; source B starts + // hourly on 1 March. Together they cover from 1 March, but A's interval can only be cut at its end. + var current = YearToDate(Now); + IReadOnlyList[] currentSources = + [ + [Single(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Coarse), Run(At(2026, 3, 15), At(2026, 12, 1), ResolutionClass.Day, divided: true)], + [Run(At(2026, 3, 1), At(2026, 12, 1), ResolutionClass.Hour)], + ]; + IReadOnlyList[] comparisonSources = + [ + [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)], + [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)], + ]; + + var result = MatchedCoverage.Match( + MatchSide.OfSources(current.From, current.To, Now, Berlin, currentSources), + MatchSide.OfSources(PreviousYear(current.From), PreviousYear(current.To), Now, Berlin, comparisonSources), + PreviousYear); + + Assert.Equal(At(2026, 3, 15), result.Current!.From); + Assert.Equal(At(2025, 3, 15), result.Comparison!.From); + } + + [Fact] + public void A_scope_without_sources_is_not_comparable() + { + var current = YearToDate(Now); + + var result = MatchedCoverage.Match( + MatchSide.OfSources(current.From, current.To, Now, Berlin, []), + MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), Now, Berlin, [MonthLabels(2025, 1, 2026, 1)]), + PreviousYear); + + Assert.False(result.IsComparable); + } + + [Fact] + public void A_meter_installed_in_March_last_year_matches_from_March_on_both_sides() + { + var result = MatchYearToDate( + Now, + [Run(At(2025, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)], + [Run(At(2025, 3, 10), At(2026, 1, 1), ResolutionClass.Hour)]); + + Assert.Equal(At(2026, 3, 10), result.Current!.From); + Assert.Equal(new DateOnly(2026, 3, 10), result.Current.FirstDay); + Assert.Equal(At(2025, 3, 10), result.Comparison!.From); + Assert.Equal(Now, result.Current.To); + } + + [Fact] + public void An_outage_in_the_comparison_year_splits_the_match_and_is_left_out_of_both_sides() + { + var result = MatchYearToDate( + Now, + [Run(At(2026, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)], + [ + Run(At(2025, 1, 1), At(2025, 3, 10, 14), ResolutionClass.Hour), + Gap(At(2025, 3, 10, 14), At(2025, 3, 13, 9), CoverageGapReason.SampleGap), + Run(At(2025, 3, 13, 9), At(2026, 1, 1), ResolutionClass.Hour), + ]); + + Assert.False(result.IsContiguous); + Assert.Equal(2, result.Pieces.Count); + Assert.Equal(At(2026, 3, 10, 14), result.Pieces[0].Current.To); + Assert.Equal(At(2026, 3, 13, 9), result.Pieces[1].Current.From); + + // The gap's row is booked at its end, 13 March 09:00: the second piece starts just past it, and the + // first ends just past the last sample before the outage. + Assert.Equal(At(2025, 3, 10, 14) + Past, result.Pieces[0].Comparison.To); + Assert.Equal(At(2025, 3, 13, 9) + Past, result.Pieces[1].Comparison.From); + + // The spans still run from the first to the last matched instant. + Assert.Equal(At(2026, 1, 1), result.Current!.From); + Assert.Equal(Now, result.Current.To); + } + + // ---- Opening balances (A-01) ---------------------------------------------------------------------- + + [Fact] + public void An_opening_balance_at_the_start_of_the_current_data_stays_out_of_the_match() + { + // D-14: the first reading's row holds consumption of unknown extent. + var firstReading = At(2026, 2, 3, 11); + + var result = MatchYearToDate( + Now, + [Run(firstReading, At(2026, 12, 1), ResolutionClass.Hour)], + [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)], + currentOpeningBalances: [firstReading]); + + Assert.Equal(firstReading + Past, result.Current!.From); + Assert.Equal(new DateOnly(2026, 2, 3), result.Current.FirstDay); + Assert.Equal(At(2025, 2, 3, 11), result.Comparison!.From); + } + + [Fact] + public void An_opening_balance_read_at_midnight_stays_out_of_a_match_starting_there() + { + // m2 #2: a first reading at 00:00 describes no time before it, so its row stays on the midnight + // (D-11 only moves rows that close an interval) and a range starting there must step past it. + var firstReading = At(2026, 2, 3); + + var result = MatchYearToDate( + Now, + [Run(firstReading, At(2026, 12, 1), ResolutionClass.Hour)], + [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)], + currentOpeningBalances: [firstReading]); + + Assert.Equal(firstReading + Past, result.Current!.From); + Assert.Equal(new DateOnly(2026, 2, 3), result.Current.FirstDay); + Assert.Equal(At(2025, 2, 3), result.Comparison!.From); + } + + [Fact] + public void An_opening_balance_in_the_comparison_year_moves_the_start_of_both_sides() + { + var firstReading = At(2025, 4, 7, 15); + + var result = MatchYearToDate( + Now, + [Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)], + [Run(firstReading, At(2026, 1, 1), ResolutionClass.Hour)], + comparisonOpeningBalances: [firstReading]); + + Assert.Equal(firstReading + Past, result.Comparison!.From); + Assert.Equal(At(2026, 4, 7, 15), result.Current!.From); + } + + // ---- Not comparable --------------------------------------------------------------------------------- + + [Fact] + public void No_coverage_in_the_comparison_period_is_not_comparable() + { + var result = MatchYearToDate( + Now, + [Run(At(2026, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)], + [Run(At(2025, 10, 1), At(2026, 1, 1), ResolutionClass.Hour)]); + + Assert.False(result.IsComparable); + Assert.Same(MatchedCoverageResult.NotComparable, result); + Assert.Null(result.Current); + Assert.Null(result.Comparison); + } + + [Fact] + public void No_coverage_in_the_current_period_is_not_comparable() + { + var result = MatchYearToDate(Now, [], [MonthLabels(2025, 1, 2026, 1)]); + + Assert.False(result.IsComparable); + } + + [Fact] + public void An_overlap_shorter_than_the_monthly_side_can_resolve_is_not_comparable() + { + // Hourly data for 1-20 January against a year of month rows: January cannot be cut on the 20th. + var now = At(2026, 1, 20, 12); + + var result = MatchYearToDate(now, [Run(At(2026, 1, 1), At(2026, 2, 1), ResolutionClass.Hour)], [MonthLabels(2025, 1, 2026, 1)]); + + Assert.False(result.IsComparable); + } + + // ---- Calendar shifts ------------------------------------------------------------------------------ + + [Fact] + public void A_previous_period_shifted_by_days_is_trimmed_on_the_side_whose_data_is_coarse() + { + // 10-19 September against 31 August - 9 September, with the earlier stretch read once a day at noon. + var now = At(2026, 9, 19, 10); + var current = Period(PeriodPreset.Custom, new DateOnly(2026, 9, 10), new DateOnly(2026, 9, 19), now); + + var result = MatchedCoverage.Match( + MatchSide.Of(current.From, current.To, now, Berlin, [Run(At(2026, 9, 1), At(2026, 10, 1), ResolutionClass.Hour)]), + MatchSide.Of(At(2026, 8, 31), ShiftDays(now, -10), now, Berlin, [Run(At(2026, 8, 30, 12), At(2026, 9, 12, 12), ResolutionClass.Day, divided: true)]), + t => ShiftDays(t, -10)); + + // The hourly side needs no trimming; the daily side ends its match at local midnight on 9 September. + Assert.Equal(At(2026, 9, 10), result.Current!.From); + Assert.Equal(At(2026, 9, 19), result.Current.To); + Assert.Equal(At(2026, 8, 31), result.Comparison!.From); + Assert.Equal(At(2026, 9, 9), result.Comparison.To); + } + + [Fact] + public void A_match_across_the_spring_DST_change_lines_up_on_local_wall_time() + { + // DST began on 30 March 2025 and 29 March 2026; the matched ranges still start and end at local midnights. + var now = At(2026, 4, 15, 12); + + var result = MatchYearToDate( + now, + [Run(At(2026, 3, 1), At(2026, 4, 16, 6), ResolutionClass.Day, divided: true)], + [Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]); + + Assert.Equal(At(2026, 3, 1), result.Current!.From); + Assert.Equal(At(2026, 4, 15), result.Current.To); + Assert.Equal(At(2025, 3, 1), result.Comparison!.From); + Assert.Equal(At(2025, 4, 15), result.Comparison.To); + Assert.Equal(new DateOnly(2025, 4, 14), result.Comparison.LastDay); + } + + [Fact] + public void A_match_on_the_long_autumn_day_cuts_both_years_at_the_same_wall_time() + { + // 25 October 2026 has 25 hours in Berlin; on 25 October 2025 the clocks had not gone back yet. + var now = At(2026, 10, 25, 12); + + var hourly = MatchYearToDate( + now, + [Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)], + [Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]); + var daily = MatchYearToDate( + now, + [Run(At(2025, 12, 31, 6), At(2026, 12, 1, 6), ResolutionClass.Day, divided: true)], + [Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]); + + Assert.Equal(now, hourly.Current!.To); + Assert.Equal(At(2025, 10, 25, 12), hourly.Comparison!.To); + Assert.Equal(TimeSpan.FromHours(1), (hourly.Current.To - hourly.Current.From) - (hourly.Comparison.To - hourly.Comparison.From)); + Assert.Equal(At(2026, 10, 25), daily.Current!.To); + Assert.Equal(At(2025, 10, 25), daily.Comparison!.To); + Assert.Equal(new DateOnly(2025, 10, 24), daily.Comparison.LastDay); + } + + [Fact] + public void March_to_date_on_the_30th_matches_all_of_February_because_the_29th_to_31st_map_onto_its_end() + { + // m2 #11: shifting by a month clamps 29-31 March to the end of February (D-06), so the shift is not + // one-to-one there: every instant from 29 March 00:00 maps to 1 March 00:00. + var now = At(2026, 3, 30, 10); + var current = Period(PeriodPreset.MonthToDate, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 30), now); + + var result = MatchedCoverage.Match( + MatchSide.Of(current.From, current.To, now, Berlin, [Run(At(2026, 1, 1), At(2026, 5, 1), ResolutionClass.Hour)]), + MatchSide.Of(At(2026, 2, 1), At(2026, 3, 1), now, Berlin, [Run(At(2026, 1, 1), At(2026, 3, 1), ResolutionClass.Hour)]), + t => ShiftMonths(t, -1)); + + Assert.Equal(At(2026, 3, 1), ShiftMonths(At(2026, 3, 29), -1)); + Assert.Equal(At(2026, 3, 1), ShiftMonths(At(2026, 3, 31, 23), -1)); + Assert.Equal(new MatchedRange(At(2026, 3, 1), now, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 30)), result.Current); + Assert.Equal(new MatchedRange(At(2026, 2, 1), At(2026, 3, 1), new DateOnly(2026, 2, 1), new DateOnly(2026, 2, 28)), result.Comparison); + } + + [Fact] + public void Year_to_date_on_29_February_matches_up_to_the_end_of_February_the_year_before() + { + // m2 #11: 29 February 2024 has no counterpart in 2023; the day maps onto 1 March 2023 00:00. + var now = At(2024, 2, 29, 10); + + var result = MatchYearToDate( + now, + [Run(At(2023, 12, 31, 6), At(2024, 3, 10, 6), ResolutionClass.Day, divided: true)], + [Run(At(2022, 12, 1), At(2023, 12, 1), ResolutionClass.Hour)]); + + Assert.Equal(new MatchedRange(At(2024, 1, 1), At(2024, 2, 29), new DateOnly(2024, 1, 1), new DateOnly(2024, 2, 28)), result.Current); + Assert.Equal(new MatchedRange(At(2023, 1, 1), At(2023, 3, 1), new DateOnly(2023, 1, 1), new DateOnly(2023, 2, 28)), result.Comparison); + } + + [Fact] + public void In_New_York_monthly_labels_match_on_New_York_month_starts() + { + var now = At(NewYork, 2026, 9, 19, 10); + var from = At(NewYork, 2026, 1, 1); + DateTimeOffset LastYear(DateTimeOffset t) => Shift(t, NewYork, months: -12); + + var result = MatchedCoverage.Match( + MatchSide.Of(from, now, now, NewYork, [MonthLabels(NewYork, 2026, 1, 2026, 7)]), + MatchSide.Of(LastYear(from), LastYear(now), now, NewYork, [MonthLabels(NewYork, 2025, 1, 2026, 1)]), + LastYear); + + Assert.Equal(new MatchedRange(At(NewYork, 2026, 1, 1), At(NewYork, 2026, 7, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 6, 30)), result.Current); + Assert.Equal(new DateTimeOffset(2026, 7, 1, 4, 0, 0, TimeSpan.Zero), result.Current!.To); + Assert.Equal(new MatchedRange(At(NewYork, 2025, 1, 1), At(NewYork, 2025, 7, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 6, 30)), result.Comparison); + } +} diff --git a/tests/Core.Tests/Analysis/MeterRoleRulesTests.cs b/tests/Core.Tests/Analysis/MeterRoleRulesTests.cs new file mode 100644 index 0000000..782e35e --- /dev/null +++ b/tests/Core.Tests/Analysis/MeterRoleRulesTests.cs @@ -0,0 +1,281 @@ +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Tests.Analysis; + +public sealed class MeterRoleRulesTests +{ + private const short Electricity = 1; + private const short Water = 2; + + private static Meter MeterOf( + int id, string name, short energyTypeId, MeterMode mode, string? role = null, DateOnly? retiredAt = null) => new() + { + Id = id, + Name = name, + EnergyTypeId = energyTypeId, + Mode = mode, + Unit = "kWh", + Meta = MeterMeta.SetRole("{}", role), + RetiredAt = retiredAt, + }; + + [Theory] + [InlineData("total_load", MeterRole.TotalLoad)] + [InlineData("grid_import", MeterRole.GridImport)] + [InlineData("grid_export", MeterRole.GridExport)] + [InlineData(" GRID_IMPORT ", MeterRole.GridImport)] + public void A_stored_token_parses_to_its_role(string token, MeterRole role) + { + Assert.True(MeterRoleRules.TryParse(token, out var parsed)); + Assert.Equal(role, parsed); + Assert.Equal(role, MeterRoleRules.Parse(token)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("pv")] + [InlineData("grid-import")] + public void Anything_else_is_no_role(string? token) + { + Assert.False(MeterRoleRules.TryParse(token, out _)); + Assert.Null(MeterRoleRules.Parse(token)); + } + + [Fact] + public void Every_role_round_trips_through_its_stored_token() + { + foreach (var role in MeterRoleRules.All) + { + Assert.Equal(role, MeterRoleRules.Parse(MeterRoleRules.Token(role))); + } + + Assert.Equal(MeterRoles.TotalLoad, MeterRoleRules.Token(MeterRole.TotalLoad)); + Assert.Equal(MeterRoles.GridImport, MeterRoleRules.Token(MeterRole.GridImport)); + Assert.Equal(MeterRoles.GridExport, MeterRoleRules.Token(MeterRole.GridExport)); + } + + [Theory] + [InlineData(MeterMode.CumulativeCounter)] + [InlineData(MeterMode.DirectDelta)] + [InlineData(MeterMode.InstantRate)] + public void Meters_that_measure_a_flow_may_hold_every_role(MeterMode mode) + { + Assert.Equal(MeterRoleRules.All, MeterRoleRules.AllowedFor(mode)); + Assert.All(MeterRoleRules.All, role => Assert.True(MeterRoleRules.IsAllowed(role, mode))); + } + + [Theory] + [InlineData(MeterMode.GenerationCounter)] + [InlineData(MeterMode.RuntimeCounter)] + [InlineData(MeterMode.ConsumableBalance)] + [InlineData(MeterMode.Virtual)] + public void Generation_runtime_tank_and_virtual_meters_hold_no_role(MeterMode mode) + { + Assert.Empty(MeterRoleRules.AllowedFor(mode)); + Assert.All(MeterRoleRules.All, role => Assert.False(MeterRoleRules.IsAllowed(role, mode))); + } + + [Fact] + public void The_effective_role_ignores_a_token_the_mode_cannot_hold() + { + Assert.Equal(MeterRole.GridImport, MeterRoleRules.Effective(MeterOf(1, "Netz", Electricity, MeterMode.CumulativeCounter, "grid_import"))); + Assert.Null(MeterRoleRules.Effective(MeterOf(2, "Solar", Electricity, MeterMode.GenerationCounter, "grid_export"))); + Assert.Null(MeterRoleRules.Effective(MeterOf(3, "Summe Solar", Electricity, MeterMode.Virtual, "total_load"))); + Assert.Null(MeterRoleRules.Effective(MeterOf(4, "Auto", Electricity, MeterMode.CumulativeCounter))); + } + + [Fact] + public void Assigning_a_held_role_names_the_meter_it_moves_from() + { + // The seeded installation: Haus holds total_load, Netz holds grid_import. + var meters = new[] + { + MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad), + MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport), + MeterOf(3, "Zähler Auto", Electricity, MeterMode.CumulativeCounter), + }; + + var holder = MeterRoleRules.CurrentHolder(meters, MeterRole.TotalLoad, Electricity, meterId: 3); + + Assert.NotNull(holder); + Assert.Equal("Zähler Haus", holder.Name); + } + + [Fact] + public void A_free_role_has_no_holder() + { + var meters = new[] + { + MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad), + MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport), + }; + + Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 2)); + } + + [Fact] + public void Re_saving_the_holder_does_not_move_the_role_from_itself() + { + var meters = new[] { MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad) }; + + Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.TotalLoad, Electricity, meterId: 1)); + } + + [Fact] + public void Roles_are_unique_per_energy_type_not_globally() + { + var meters = new[] + { + MeterOf(1, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport), + MeterOf(2, "Hauswasser", Water, MeterMode.CumulativeCounter), + }; + + Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridImport, Water, meterId: 2)); + } + + [Fact] + public void Legacy_duplicates_and_invalid_leftovers_all_give_the_role_up() + { + var meters = new[] + { + MeterOf(9, "Einspeisung alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridExport), + MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport), + MeterOf(7, "Einspeisung Garage", Electricity, MeterMode.DirectDelta, MeterRoles.GridExport), + MeterOf(5, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter), + MeterOf(6, "Wasser", Water, MeterMode.CumulativeCounter, MeterRoles.GridExport), + }; + + var holders = MeterRoleRules.Holders(meters, MeterRole.GridExport, Electricity, exceptMeterId: 5); + + // Meters that really play the role first, then the leftover a generation counter cannot hold. + Assert.Equal([7, 9, 4], holders.Select(m => m.Id)); + } + + [Fact] + public void The_role_is_said_to_move_from_the_meter_that_played_it_not_from_an_invalid_leftover() + { + // Solar 1 (id 4) still stores a grid_export token it cannot hold; Einspeisung (id 9) is the export meter. + var meters = new[] + { + MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport), + MeterOf(9, "Einspeisung", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridExport), + MeterOf(12, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter), + }; + + var holder = MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 12); + + Assert.NotNull(holder); + Assert.Equal("Einspeisung", holder.Name); + } + + [Fact] + public void An_invalid_leftover_is_named_only_when_no_meter_played_the_role() + { + var meters = new[] + { + MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport), + MeterOf(12, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter), + }; + + Assert.Equal(4, MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 12)!.Id); + } + + [Fact] + public void A_retired_meter_keeps_its_role_when_its_replacement_takes_it() + { + // A-07: the old grid meter measured the grid import of its own service period. + var meters = new[] + { + MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30)), + MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter), + }; + var replacement = MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport); + + Assert.Empty(MeterRoleRules.Holders(meters, MeterRole.GridImport, Electricity, exceptMeterId: 15)); + Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridImport, Electricity, meterId: 15)); + Assert.Empty(MeterRoleRules.Displaced(meters, replacement, MeterRole.GridImport)); + } + + [Fact] + public void A_retired_meter_saved_with_a_role_displaces_no_meter_in_service() + { + var meters = new[] + { + MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport), + MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter), + }; + var retired = MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30)); + + Assert.Empty(MeterRoleRules.Displaced(meters, retired, MeterRole.GridImport)); + } + + [Fact] + public void A_meter_in_service_saved_with_a_role_displaces_every_other_holder_in_service() + { + var meters = new[] + { + MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad), + MeterOf(3, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.TotalLoad), + MeterOf(8, "Haus alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad, new DateOnly(2020, 12, 31)), + MeterOf(20, "Hauptzähler", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad), + }; + var taker = MeterOf(20, "Hauptzähler", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad); + + var displaced = MeterRoleRules.Displaced(meters, taker, MeterRole.TotalLoad); + + Assert.Equal([1, 3], displaced.Select(m => m.Id)); + } + + [Fact] + public void A_meter_whose_mode_cannot_hold_the_role_displaces_nobody() + { + var meters = new[] { MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport) }; + var generation = MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridImport); + + Assert.Empty(MeterRoleRules.Displaced(meters, generation, MeterRole.GridImport)); + } + + [Fact] + public void A_meter_counts_as_retired_once_its_lifecycle_end_is_recorded() + { + var retired = RoleCandidate.From(MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30))); + var inService = RoleCandidate.From(MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, " GRID_IMPORT ")); + + Assert.Equal(new RoleCandidate(2, Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: true), retired); + Assert.False(inService.IsRetired); + Assert.Equal(MeterRole.GridImport, inService.StoredRole); + Assert.Equal(MeterRole.GridImport, inService.EffectiveRole); + } + + [Fact] + public void A_caller_decides_what_retired_means_by_building_the_candidates_itself() + { + // An analysis input that treats a deactivated meter as retired passes the flag directly. + var candidates = new[] + { + new RoleCandidate(2, 1, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: true), + new RoleCandidate(4, 1, MeterMode.GenerationCounter, MeterRoles.GridImport, IsRetired: false), + new RoleCandidate(15, 1, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: false), + new RoleCandidate(16, 2, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: false), + }; + + var holders = MeterRoleRules.Holders(candidates, MeterRole.GridImport, energyTypeId: 1); + + Assert.Equal([15, 4], holders.Select(c => c.MeterId)); + Assert.Equal(15, MeterRoleRules.CurrentHolder(candidates, MeterRole.GridImport, 1, meterId: 30)!.MeterId); + Assert.Null(holders[1].EffectiveRole); + } + + [Fact] + public void Malformed_meta_holds_no_role() + { + var broken = MeterOf(1, "Kaputt", Electricity, MeterMode.CumulativeCounter); + broken.Meta = "not json"; + + Assert.Empty(MeterRoleRules.Holders([broken], MeterRole.TotalLoad, Electricity)); + Assert.Null(MeterRoleRules.Effective(broken)); + } +} diff --git a/tests/Core.Tests/Analysis/NormalizedQuantityTests.cs b/tests/Core.Tests/Analysis/NormalizedQuantityTests.cs new file mode 100644 index 0000000..048d18d --- /dev/null +++ b/tests/Core.Tests/Analysis/NormalizedQuantityTests.cs @@ -0,0 +1,482 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; +using static MeterVault.Core.Tests.TestData; + +namespace MeterVault.Core.Tests.Analysis; + +public sealed class NormalizedQuantityTests +{ + private static readonly TankInfo OilTank = new("L", TankRateMode.Empirical, null); + + private static Meter MeterOf(string name, MeterMode mode, string unit, string? role = null) => new() + { + Id = 1, + Name = name, + Mode = mode, + Unit = unit, + Meta = role is null ? "{}" : MeterMeta.SetRole("{}", role), + }; + + public static TheoryData SeededMeters => new() + { + // The reference installation (ReferenceDataImporter), as D-22 classifies it. + { "Zähler Haus", MeterMode.CumulativeCounter, "kWh", MeterRoles.TotalLoad, QuantityKind.Consumption, "kWh" }, + { "Zähler Netz", MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, QuantityKind.Consumption, "kWh" }, + { "Zähler Auto", MeterMode.CumulativeCounter, "kWh", null, QuantityKind.Consumption, "kWh" }, + { "Zähler Solar 1", MeterMode.GenerationCounter, "kWh", null, QuantityKind.Generation, "kWh" }, + { "Zähler Solar 2", MeterMode.GenerationCounter, "kWh", null, QuantityKind.Generation, "kWh" }, + { "Zähler Wasser", MeterMode.CumulativeCounter, "m3", null, QuantityKind.Consumption, "m³" }, + { "Brenner", MeterMode.RuntimeCounter, "h", null, QuantityKind.Runtime, "h" }, + }; + + [Theory] + [MemberData(nameof(SeededMeters))] + public void Seeded_meters_have_the_kind_and_unit_their_normalizer_books( + string name, MeterMode mode, string unit, string? role, QuantityKind kind, string normalizedUnit) + { + var quantity = NormalizedQuantity.Of(MeterOf(name, mode, unit, role)); + + Assert.Equal(new NormalizedQuantity(kind, normalizedUnit), quantity); + } + + [Fact] + public void The_seeded_oil_tank_books_litres_of_consumption() + { + var tank = new Tank { MeterId = 1, Capacity = 7000, Unit = "L" }; + + var quantity = NormalizedQuantity.Of(MeterOf("Öltank", MeterMode.ConsumableBalance, "L"), tank); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L"), quantity); + } + + [Fact] + public void Summe_Solar_is_the_generation_it_declares() + { + // D-28: the reference importer writes m(Solar 1) + m(Solar 2) as generation in kWh. + var quantity = NormalizedQuantity.Of( + MeterOf("Summe Solar", MeterMode.Virtual, "kWh"), + virtualResult: new DeclaredVirtualResult(QuantityKind.Generation, "kWh")); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh"), quantity); + } + + [Fact] + public void A_runtime_counter_without_a_tank_counts_hours() + { + var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, null, null); + + Assert.Equal(QuantityKind.Runtime, quantity.Kind); + Assert.Equal("h", quantity.Unit); + Assert.Equal(QuantityNotes.None, quantity.Notes); + Assert.Equal(Provenance.None, quantity.ImpliedProvenance); + } + + [Theory] + [InlineData("h", "h")] + [InlineData("Std", "h")] + [InlineData("Betriebsstunden", "h")] + [InlineData("min", "min")] + [InlineData("Minuten", "min")] + [InlineData("s", "s")] + public void A_runtime_counter_books_its_register_in_the_registers_own_time_unit(string meterUnit, string expected) + { + Assert.Equal( + new NormalizedQuantity(QuantityKind.Runtime, expected), + NormalizedQuantity.Of(MeterMode.RuntimeCounter, meterUnit, null, null, null)); + } + + [Fact] + public void A_runtime_register_in_minutes_books_minutes_exactly_as_the_normalizer_does() + { + var context = new NormalizationContext + { + Meter = new MeterConfig { MeterId = 21, Mode = MeterMode.RuntimeCounter, Unit = "min", InitialBaseline = 600 }, + Readings = [Reading(21, Month(2023, 1), 600), Reading(21, Month(2023, 2), 720)], + }; + + var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount); + + // 120 minutes of burner time, not 120 hours: an "EUR/h" price must see minutes, not hours. + Assert.Equal(120d, booked); + Assert.Equal("min", NormalizedQuantity.Of(MeterMode.RuntimeCounter, "min", null, null, null).Unit); + Assert.Equal(1 / 60d, TariffUnit.Applicability("EUR/h", "min", TariffComponent.UnitPrice).Factor, 12); + } + + [Theory] + [InlineData("")] + [InlineData("Stk")] + [InlineData("kWh")] + public void A_runtime_counter_whose_register_names_no_time_counts_hours(string meterUnit) + { + Assert.Equal( + new NormalizedQuantity(QuantityKind.Runtime, "h"), + NormalizedQuantity.Of(MeterMode.RuntimeCounter, meterUnit, null, null, null)); + } + + [Theory] + [InlineData("min")] + [InlineData("s")] + public void A_fixed_hourly_rate_on_a_register_that_does_not_count_hours_is_flagged(string meterUnit) + { + var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, meterUnit, null, new TankInfo("L", TankRateMode.Fixed, 2.0), null); + + Assert.Equal("L", quantity.Unit); + Assert.Equal(QuantityNotes.FixedRateEstimate | QuantityNotes.RegisterNotInHours, quantity.Notes); + } + + [Fact] + public void A_runtime_counter_with_a_fixed_rate_tank_books_the_tank_unit_as_an_estimate() + { + var tank = new TankInfo("Liter", TankRateMode.Fixed, 2.0); + + var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, tank, null); + + // D-20: the kind stays runtime; the volume is hours × nozzle rate, so it is estimated. + Assert.Equal(QuantityKind.Runtime, quantity.Kind); + Assert.Equal("L", quantity.Unit); + Assert.Equal(QuantityNotes.FixedRateEstimate, quantity.Notes); + Assert.Equal(Provenance.Estimated, quantity.ImpliedProvenance); + } + + [Fact] + public void A_runtime_counter_with_an_empirical_tank_still_counts_hours() + { + var tank = new TankInfo("L", TankRateMode.Empirical, 2.0); + + Assert.Equal( + new NormalizedQuantity(QuantityKind.Runtime, "h"), + NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, tank, null)); + } + + [Fact] + public void A_fixed_tank_without_a_rate_books_hours_exactly_as_the_normalizer_does() + { + var tank = new TankInfo("L", TankRateMode.Fixed, null); + var context = new NormalizationContext + { + Meter = new MeterConfig + { + MeterId = 20, + Mode = MeterMode.RuntimeCounter, + Unit = "h", + InitialBaseline = 100, + Tank = new TankConfig { Capacity = 7000, RateMode = TankRateMode.Fixed, FixedRate = null }, + }, + Readings = [Reading(20, Month(2023, 1), 100), Reading(20, Month(2023, 2), 167)], + }; + + var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount); + + Assert.Equal(67d, booked); // hours, not litres + Assert.Equal("h", NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, tank, null).Unit); + } + + [Fact] + public void A_fixed_rate_tank_without_a_unit_falls_back_to_litres_and_says_so() + { + var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, new TankInfo(" ", TankRateMode.Fixed, 1.8), null); + + Assert.Equal("L", quantity.Unit); + Assert.Equal(QuantityNotes.FixedRateEstimate | QuantityNotes.NoTankUnit, quantity.Notes); + } + + [Theory] + [InlineData("kW", "kWh")] + [InlineData("W", "Wh")] + [InlineData("MW", "MWh")] + [InlineData("mW", "mWh")] + [InlineData("W/m²", "Wh/m²")] + [InlineData("L/h", "L")] + [InlineData("m3/h", "m³")] + public void An_instant_rate_meter_books_its_rate_unit_integrated_over_hours(string rateUnit, string expected) + { + var quantity = NormalizedQuantity.Of(MeterMode.InstantRate, rateUnit, null, null, null); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, expected), quantity); + } + + [Fact] + public void An_instant_rate_in_kilowatts_integrates_to_the_kilowatt_hours_the_normalizer_books() + { + // 2 kW held for 90 minutes is 3 kWh. + var start = new DateTimeOffset(2026, 3, 1, 10, 0, 0, TimeSpan.Zero); + var context = new NormalizationContext + { + Meter = new MeterConfig { MeterId = 7, Mode = MeterMode.InstantRate, Unit = "kW" }, + Readings = [DayReading(7, start, 2), DayReading(7, start.AddMinutes(90), 2)], + }; + + var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount); + + Assert.Equal(3d, booked, 9); + Assert.Equal("kWh", NormalizedQuantity.Of(MeterMode.InstantRate, "kW", null, null, null).Unit); + } + + [Theory] + [InlineData("kWh", "kWh")] + [InlineData("Stk", "stk")] + public void An_instant_rate_in_a_unit_that_is_not_a_rate_is_flagged_as_assumed_per_hour(string rateUnit, string expected) + { + var quantity = NormalizedQuantity.Of(MeterMode.InstantRate, rateUnit, null, null, null); + + Assert.Equal(expected, quantity.Unit); + Assert.Equal(QuantityNotes.RateAssumedPerHour, quantity.Notes); + } + + [Theory] + [InlineData("L/min", "L/min")] + [InlineData("m³/s", "m³/s")] + [InlineData("l/Tag", "L/tag")] + public void An_instant_rate_over_another_time_keeps_its_rate_unit_so_no_price_per_quantity_applies( + string rateUnit, string expected) + { + var quantity = NormalizedQuantity.Of(MeterMode.InstantRate, rateUnit, null, null, null); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, expected, QuantityNotes.RateNotPerHour), quantity); + Assert.Equal(TariffUnitFit.Mismatch, TariffUnit.Applicability("EUR/L", quantity.Unit, TariffComponent.UnitPrice).Fit); + Assert.Equal(TariffUnitFit.Mismatch, TariffUnit.Applicability("EUR/m³", quantity.Unit, TariffComponent.UnitPrice).Fit); + } + + [Fact] + public void A_flow_per_minute_books_a_sixtieth_of_its_litres_so_calling_them_litres_would_be_wrong() + { + // 10 L/min held for an hour is 600 L; the normalizer books 10 because it integrates per hour. + var start = new DateTimeOffset(2026, 3, 1, 10, 0, 0, TimeSpan.Zero); + var context = new NormalizationContext + { + Meter = new MeterConfig { MeterId = 8, Mode = MeterMode.InstantRate, Unit = "L/min" }, + Readings = [DayReading(8, start, 10), DayReading(8, start.AddHours(1), 10)], + }; + + var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount); + + Assert.Equal(10d, booked, 9); + Assert.NotEqual("L", NormalizedQuantity.Of(MeterMode.InstantRate, "L/min", null, null, null).Unit); + } + + [Fact] + public void An_instant_rate_meter_without_a_unit_carries_no_note() + { + Assert.Equal( + new NormalizedQuantity(QuantityKind.Consumption, string.Empty), + NormalizedQuantity.Of(MeterMode.InstantRate, " ", null, null, null)); + } + + [Theory] + [InlineData(MeterMode.CumulativeCounter, "kWh", "kWh")] + [InlineData(MeterMode.DirectDelta, "kWh", "kWh")] + [InlineData(MeterMode.InstantRate, "kW", "kWh")] + public void A_grid_export_meter_measures_export_never_consumption(MeterMode mode, string unit, string expectedUnit) + { + var quantity = NormalizedQuantity.Of(mode, unit, MeterRoles.GridExport, null, null); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Export, expectedUnit), quantity); + } + + [Theory] + [InlineData(MeterRoles.TotalLoad)] + [InlineData(MeterRoles.GridImport)] + [InlineData(null)] + [InlineData("pv_inverter")] + public void Other_roles_leave_a_counter_measuring_consumption(string? role) + { + var quantity = NormalizedQuantity.Of(MeterMode.CumulativeCounter, "kWh", role, null, null); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "kWh"), quantity); + } + + [Fact] + public void The_role_is_read_from_the_meters_meta() + { + var export = MeterOf("Einspeisung", MeterMode.CumulativeCounter, "kWh", "GRID_EXPORT "); + + Assert.Equal(QuantityKind.Export, NormalizedQuantity.Of(export).Kind); + } + + [Fact] + public void A_role_the_mode_cannot_hold_is_ignored_and_flagged() + { + var generation = NormalizedQuantity.Of(MeterMode.GenerationCounter, "kWh", MeterRoles.GridExport, null, null); + var tank = NormalizedQuantity.Of(MeterMode.ConsumableBalance, "L", MeterRoles.TotalLoad, OilTank, null); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh", QuantityNotes.RoleIgnored), generation); + Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L", QuantityNotes.RoleIgnored), tank); + } + + [Fact] + public void A_tank_books_in_the_tank_unit_not_the_level_unit() + { + // Dipstick readings in cm are calibrated to the tank's litres. + var quantity = NormalizedQuantity.Of(MeterMode.ConsumableBalance, "cm", null, new TankInfo("Liter", TankRateMode.Empirical, null), null); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L"), quantity); + } + + [Fact] + public void A_tank_meter_without_a_tank_falls_back_to_its_own_unit() + { + var quantity = NormalizedQuantity.Of(MeterMode.ConsumableBalance, "Liter", null, null, null); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L", QuantityNotes.NoTankUnit), quantity); + } + + [Theory] + [InlineData("m3", "m³")] + [InlineData("KWH", "kWh")] + [InlineData("Stk", "stk")] + public void A_direct_delta_meter_books_consumption_in_its_own_normalized_unit(string unit, string expected) + { + Assert.Equal( + new NormalizedQuantity(QuantityKind.Consumption, expected), + NormalizedQuantity.Of(MeterMode.DirectDelta, unit, null, null, null)); + } + + [Fact] + public void A_generation_counter_books_generation_in_its_own_unit() + { + Assert.Equal( + new NormalizedQuantity(QuantityKind.Generation, "MWh"), + NormalizedQuantity.Of(MeterMode.GenerationCounter, "MWH", null, null, null)); + } + + [Theory] + [InlineData(QuantityKind.Consumption, "m3", "m³")] + [InlineData(QuantityKind.Generation, "kWh", "kWh")] + [InlineData(QuantityKind.Net, "kWh", "kWh")] + [InlineData(QuantityKind.Indicator, "%", "%")] + public void A_virtual_meter_is_its_declared_result(QuantityKind kind, string unit, string expectedUnit) + { + var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, new DeclaredVirtualResult(kind, unit)); + + Assert.Equal(new NormalizedQuantity(kind, expectedUnit), quantity); + } + + [Fact] + public void A_virtual_meter_without_a_declaration_assumes_consumption_in_its_unit_and_says_so() + { + var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, null); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "kWh", QuantityNotes.UndeclaredResult), quantity); + } + + [Theory] + [InlineData(QuantityKind.Cost)] + [InlineData(QuantityKind.Export)] + [InlineData(QuantityKind.Runtime)] + public void A_virtual_meter_cannot_declare_a_kind_outside_D25(QuantityKind kind) + { + var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, new DeclaredVirtualResult(kind, "kWh")); + + Assert.Equal(QuantityKind.Consumption, quantity.Kind); + Assert.Equal(QuantityNotes.UndeclaredResult, quantity.Notes); + } + + [Fact] + public void A_virtual_declaration_without_a_unit_keeps_the_meters_unit() + { + var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kwh", null, null, new DeclaredVirtualResult(QuantityKind.Generation, null)); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh"), quantity); + } + + [Fact] + public void A_virtual_meter_cannot_hold_a_role() + { + var quantity = NormalizedQuantity.Of( + MeterMode.Virtual, "kWh", MeterRoles.GridExport, null, new DeclaredVirtualResult(QuantityKind.Generation, "kWh")); + + Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh", QuantityNotes.RoleIgnored), quantity); + } + + [Fact] + public void Deconstruction_gives_kind_and_unit_as_D20_writes_it() + { + var (kind, unit) = NormalizedQuantity.Of(MeterMode.CumulativeCounter, "m3", null, null, null); + var (_, _, notes) = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, new TankInfo("L", TankRateMode.Fixed, 2), null); + + Assert.Equal(QuantityKind.Consumption, kind); + Assert.Equal("m³", unit); + Assert.Equal(QuantityNotes.FixedRateEstimate, notes); + } + + [Fact] + public void A_legacy_sum_of_generation_meters_is_the_generation_it_adds_up() + { + // Summe Solar before D-28 converts it: the implied sum of Solar 1 and Solar 2. + var solar = NormalizedQuantity.Of(MeterMode.GenerationCounter, "kWh", null, null, null); + var declared = DeclaredVirtualResult.FromSources([solar, solar with { Unit = "KWH" }]); + + Assert.Equal(new DeclaredVirtualResult(QuantityKind.Generation, "kWh"), declared); + Assert.Equal( + new NormalizedQuantity(QuantityKind.Generation, "kWh"), + NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, declared)); + } + + [Fact] + public void A_legacy_sum_takes_the_canonical_unit_of_its_sources() + { + var declared = DeclaredVirtualResult.FromSources([QuantityKind.Consumption, QuantityKind.Consumption], ["m3", "m³"]); + + Assert.Equal(new DeclaredVirtualResult(QuantityKind.Consumption, "m³"), declared); + } + + public static TheoryData SumsThatNeedConfiguration => new() + { + { [], [] }, + { [QuantityKind.Consumption, QuantityKind.Generation], ["kWh", "kWh"] }, + { [QuantityKind.Consumption, QuantityKind.Consumption], ["kWh", "m³"] }, + { [QuantityKind.Consumption, QuantityKind.Consumption], ["kWh", null] }, + { [QuantityKind.Indicator, QuantityKind.Indicator], ["%", "%"] }, + { [QuantityKind.Export], ["kWh"] }, + { [QuantityKind.Runtime, QuantityKind.Runtime], ["h", "h"] }, + { [QuantityKind.Cost], ["EUR"] }, + }; + + [Theory] + [MemberData(nameof(SumsThatNeedConfiguration))] + public void A_legacy_sum_that_mixes_kinds_or_units_or_adds_what_no_virtual_meter_may_declare_needs_configuration( + QuantityKind[] kinds, string?[] units) + { + var declared = DeclaredVirtualResult.FromSources(kinds, units); + + Assert.Null(declared); + Assert.Equal( + QuantityNotes.UndeclaredResult, + NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, declared).Notes); + } + + [Fact] + public void A_legacy_sum_over_a_virtual_source_that_is_itself_undeclared_needs_configuration() + { + var solar = NormalizedQuantity.Of(MeterMode.GenerationCounter, "kWh", null, null, null); + var undeclared = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, null); + + Assert.Null(DeclaredVirtualResult.FromSources([solar, undeclared])); + Assert.Null(DeclaredVirtualResult.FromSources([undeclared])); + } + + [Fact] + public void A_legacy_sum_of_net_balances_stays_net() + { + Assert.Equal( + new DeclaredVirtualResult(QuantityKind.Net, "kWh"), + DeclaredVirtualResult.FromSources([QuantityKind.Net, QuantityKind.Net], ["kWh", "kwh"])); + } + + [Fact] + public void A_legacy_sum_whose_sources_have_no_unit_keeps_the_meters_unit() + { + var declared = DeclaredVirtualResult.FromSources([QuantityKind.Consumption], [" "]); + + Assert.Equal(new DeclaredVirtualResult(QuantityKind.Consumption, null), declared); + Assert.Equal("kWh", NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, declared).Unit); + } + + [Fact] + public void Kinds_and_units_of_a_legacy_sum_must_describe_the_same_sources() + { + Assert.Throws(() => DeclaredVirtualResult.FromSources([QuantityKind.Consumption], ["kWh", "kWh"])); + } +} diff --git a/tests/Core.Tests/Analysis/PeriodResolverTests.cs b/tests/Core.Tests/Analysis/PeriodResolverTests.cs new file mode 100644 index 0000000..a838606 --- /dev/null +++ b/tests/Core.Tests/Analysis/PeriodResolverTests.cs @@ -0,0 +1,425 @@ +using MeterVault.Core.Analysis; +using static MeterVault.Core.Tests.Analysis.AnalysisClock; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// A period resolves once, in the instance zone, into local dates for display and a half-open UTC range +/// for queries (D-02 – D-04). What these pin down: ranges start at local midnight, never UTC midnight; +/// "today" is the local date; to-date periods stop at the captured now; and every preset is computed from +/// frozen instants, so none of this depends on when the tests run. +/// +public sealed class PeriodResolverTests +{ + private static ResolvedPeriod Resolve(PeriodPreset preset, DateTimeOffset now, TimeZoneInfo? zone = null) => + PeriodResolver.Resolve(preset, null, null, now, zone ?? Berlin); + + private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset now, TimeZoneInfo? zone = null) => + PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now, zone ?? Berlin); + + [Fact] + public void Month_to_date_in_Berlin_on_19_September_runs_from_local_midnight_on_the_1st_to_now() + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var period = Resolve(PeriodPreset.MonthToDate, now); + + Assert.Equal(Day(2026, 9, 1), period.FirstDay); + Assert.Equal(Day(2026, 9, 19), period.LastDay); + Assert.Equal(Utc(2026, 8, 31, 22), period.From); + Assert.Equal(Utc(2026, 9, 19, 12, 37), period.To); + Assert.Equal(now, period.Now); + Assert.True(period.IsToDate); + Assert.False(period.ExtendsPastNow); + Assert.False(period.NotYetOccurred); + Assert.Same(Berlin, period.Zone); + + // The named range is the whole month; comparisons and projections read that, the display reads the cut. + Assert.Equal(Day(2026, 9, 19), period.EffectiveLastDay()); + Assert.Equal(Day(2026, 9, 30), period.NominalLastDay()); + Assert.Equal(Utc(2026, 9, 30, 22), period.NominalEnd()); + } + + [Theory] + [InlineData(PeriodPreset.LastMonth, "2026-08-01", "2026-08-31", "2026-07-31T22:00Z", "2026-08-31T22:00Z", false)] + [InlineData(PeriodPreset.YearToDate, "2026-01-01", "2026-09-19", "2025-12-31T23:00Z", null, true)] + [InlineData(PeriodPreset.PreviousYear, "2025-01-01", "2025-12-31", "2024-12-31T23:00Z", "2025-12-31T23:00Z", false)] + [InlineData(PeriodPreset.Last12Months, "2025-10-01", "2026-09-19", "2025-09-30T22:00Z", null, true)] + [InlineData(PeriodPreset.Last24Months, "2024-10-01", "2026-09-19", "2024-09-30T22:00Z", null, true)] + public void Every_preset_on_19_September_resolves_to_its_local_calendar_range( + PeriodPreset preset, string first, string last, string from, string? to, bool toDate) + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var period = Resolve(preset, now); + + Assert.Equal(Iso(first), period.FirstDay); + Assert.Equal(Iso(last), period.LastDay); + Assert.Equal(IsoInstant(from), period.From); + Assert.Equal(to is null ? now : IsoInstant(to), period.To); + Assert.Equal(toDate, period.IsToDate); + Assert.False(period.ExtendsPastNow); + } + + [Fact] + public void The_last_12_months_are_twelve_calendar_months_ending_with_the_current_partial_one() + { + var period = Resolve(PeriodPreset.Last12Months, At(Berlin, 2026, 9, 19, 14, 37)); + + // Not 13 months, and no future month: October 2025 through September 2026, stopping now. + Assert.Equal(Day(2025, 10, 1), period.FirstDay); + Assert.Equal(Day(2026, 9, 30), period.NominalLastDay()); + Assert.Equal(period.Now, period.To); + } + + [Fact] + public void Half_an_hour_into_New_Year_the_current_month_and_year_are_2027_although_UTC_is_still_in_2026() + { + var now = At(Berlin, 2027, 1, 1, 0, 30); + Assert.Equal(Utc(2026, 12, 31, 23, 30), now.ToUniversalTime()); + + var month = Resolve(PeriodPreset.MonthToDate, now); + var year = Resolve(PeriodPreset.YearToDate, now); + var lastMonth = Resolve(PeriodPreset.LastMonth, now); + var previousYear = Resolve(PeriodPreset.PreviousYear, now); + var last12 = Resolve(PeriodPreset.Last12Months, now); + + Assert.Equal((Day(2027, 1, 1), Day(2027, 1, 1)), (month.FirstDay, month.LastDay)); + Assert.Equal((Utc(2026, 12, 31, 23), Utc(2026, 12, 31, 23, 30)), (month.From, month.To)); + Assert.Equal((month.From, month.To), (year.From, year.To)); + Assert.Equal(Day(2027, 12, 31), year.NominalLastDay()); + + Assert.Equal((Day(2026, 12, 1), Day(2026, 12, 31)), (lastMonth.FirstDay, lastMonth.LastDay)); + Assert.Equal((Utc(2026, 11, 30, 23), Utc(2026, 12, 31, 23)), (lastMonth.From, lastMonth.To)); + + Assert.Equal((Day(2026, 1, 1), Day(2026, 12, 31)), (previousYear.FirstDay, previousYear.LastDay)); + Assert.Equal((Utc(2025, 12, 31, 23), Utc(2026, 12, 31, 23)), (previousYear.From, previousYear.To)); + + Assert.Equal(Day(2026, 2, 1), last12.FirstDay); + Assert.Equal(Day(2027, 1, 1), last12.LastDay); + } + + [Fact] + public void A_month_containing_the_spring_DST_change_is_one_hour_short_and_starts_in_winter_time() + { + var march = Resolve(PeriodPreset.LastMonth, At(Berlin, 2026, 4, 1, 9, 0)); + + Assert.Equal(Utc(2026, 2, 28, 23), march.From); + Assert.Equal(Utc(2026, 3, 31, 22), march.To); + Assert.Equal(TimeSpan.FromHours((31 * 24) - 1), march.To - march.From); + } + + [Fact] + public void On_the_spring_DST_day_itself_month_to_date_runs_from_the_1st_in_winter_time_to_now_in_summer_time() + { + var now = At(Berlin, 2026, 3, 29, 10, 0); + Assert.Equal(TimeSpan.FromHours(2), now.Offset); + + var period = Resolve(PeriodPreset.MonthToDate, now); + + Assert.Equal(Utc(2026, 2, 28, 23), period.From); + Assert.Equal(Utc(2026, 3, 29, 8), period.To); + Assert.Equal(Day(2026, 3, 29), period.LastDay); + } + + [Fact] + public void A_month_containing_the_autumn_DST_change_is_one_hour_long() + { + var october = Resolve(PeriodPreset.LastMonth, At(Berlin, 2026, 11, 2, 9, 0)); + + Assert.Equal(Utc(2026, 9, 30, 22), october.From); + Assert.Equal(Utc(2026, 10, 31, 23), october.To); + Assert.Equal(TimeSpan.FromHours((31 * 24) + 1), october.To - october.From); + } + + [Fact] + public void In_the_repeated_autumn_hour_today_is_still_the_DST_day_and_the_cut_is_the_exact_instant() + { + // 02:30 in winter time, the second time the clock shows 02:30 on 25 October 2026. + var now = At(2026, 10, 25, 2, 30, offsetHours: 1); + + var period = Resolve(PeriodPreset.MonthToDate, now); + + Assert.Equal(Day(2026, 10, 25), period.LastDay); + Assert.Equal(Utc(2026, 10, 25, 1, 30), period.To); + } + + [Fact] + public void Behind_UTC_the_local_date_decides_the_month_even_when_UTC_has_moved_on() + { + // 21:00 on 30 September in New York is already 1 October in UTC. + var now = At(NewYork, 2026, 9, 30, 21, 0); + Assert.Equal(Utc(2026, 10, 1, 1), now.ToUniversalTime()); + + var period = Resolve(PeriodPreset.MonthToDate, now, NewYork); + + Assert.Equal(Day(2026, 9, 1), period.FirstDay); + Assert.Equal(Day(2026, 9, 30), period.LastDay); + Assert.Equal(Utc(2026, 9, 1, 4), period.From); + Assert.Equal(Utc(2026, 10, 1, 1), period.To); + } + + [Fact] + public void Behind_UTC_a_month_ending_in_winter_time_spans_its_local_midnights() + { + // November 2026 starts in daylight time (EDT, -4) and ends in standard time (EST, -5). + var november = Resolve(PeriodPreset.LastMonth, At(NewYork, 2026, 12, 1, 0, 30), NewYork); + + Assert.Equal(Utc(2026, 11, 1, 4), november.From); + Assert.Equal(Utc(2026, 12, 1, 5), november.To); + } + + [Fact] + public void Every_instant_is_stored_in_UTC_whatever_offset_now_arrives_with() + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var period = Resolve(PeriodPreset.YearToDate, now); + + Assert.Equal(TimeSpan.Zero, period.From.Offset); + Assert.Equal(TimeSpan.Zero, period.To.Offset); + Assert.Equal(TimeSpan.Zero, period.Now.Offset); + } + + [Fact] + public void A_custom_range_in_the_past_is_complete_and_ends_at_the_local_midnight_after_its_last_day() + { + var period = Custom(Day(2026, 6, 1), Day(2026, 6, 30), At(Berlin, 2026, 9, 19, 14, 37)); + + Assert.Equal(Utc(2026, 5, 31, 22), period.From); + Assert.Equal(Utc(2026, 6, 30, 22), period.To); + Assert.False(period.IsToDate); + Assert.False(period.ExtendsPastNow); + } + + [Fact] + public void A_custom_range_reaching_past_now_is_capped_at_now_and_keeps_the_requested_dates() + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var period = Custom(Day(2026, 9, 1), Day(2026, 12, 31), now); + + Assert.Equal(Day(2026, 9, 1), period.FirstDay); + Assert.Equal(Day(2026, 12, 31), period.LastDay); + Assert.Equal(Utc(2026, 8, 31, 22), period.From); + Assert.Equal(now, period.To); + Assert.True(period.IsToDate); + Assert.True(period.ExtendsPastNow); + Assert.False(period.NotYetOccurred); + Assert.Equal(Day(2026, 9, 19), period.EffectiveLastDay()); + + // Rows recorded after now are looked for up to the end of what was asked. + Assert.Equal(Utc(2026, 12, 31, 23), period.NominalEnd()); + } + + [Fact] + public void A_custom_range_ending_today_is_cut_at_now_but_does_not_extend_past_it() + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var period = Custom(Day(2026, 9, 10), Day(2026, 9, 19), now); + + Assert.Equal(now, period.To); + Assert.True(period.IsToDate); + Assert.False(period.ExtendsPastNow); + } + + [Fact] + public void A_custom_range_entirely_in_the_future_has_not_occurred_and_an_empty_query_range() + { + var period = Custom(Day(2027, 1, 1), Day(2027, 3, 31), At(Berlin, 2026, 9, 19, 14, 37)); + + Assert.True(period.NotYetOccurred); + Assert.Equal(Day(2027, 1, 1), period.FirstDay); + Assert.Equal(Day(2027, 3, 31), period.LastDay); + Assert.Equal(Utc(2026, 12, 31, 23), period.From); + + // Nothing can be counted as an actual: [From, To) is empty. + Assert.Equal(period.From, period.To); + Assert.False(period.IsToDate); + Assert.True(period.ExtendsPastNow); + Assert.False(period.HasNoHistory()); + Assert.True(period.HasNotStarted()); + } + + [Fact] + public void A_range_that_has_not_started_has_no_effective_days_so_no_rollup_day_is_summed_as_an_actual() + { + // Summing daily rollups from FirstDay to EffectiveLastDay must not reach into 2027: every row there + // is recorded after now (D-04). + var period = Custom(Day(2027, 1, 1), Day(2027, 3, 31), At(Berlin, 2026, 9, 19, 14, 37)); + + Assert.Equal(Day(2026, 12, 31), period.EffectiveLastDay()); + Assert.True(period.EffectiveLastDay() < period.FirstDay); + } + + [Fact] + public void The_effective_last_day_is_today_for_a_to_date_period_and_the_last_day_for_a_complete_one() + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + Assert.Equal(Day(2026, 9, 19), Custom(Day(2026, 9, 1), Day(2026, 12, 31), now).EffectiveLastDay()); + Assert.Equal(Day(2026, 8, 31), Resolve(PeriodPreset.LastMonth, now).EffectiveLastDay()); + Assert.Equal(Day(2026, 9, 18), PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin).EffectiveLastDay()); + } + + [Fact] + public void At_the_exact_local_midnight_that_starts_a_month_its_month_to_date_has_started_with_nothing_elapsed() + { + var now = At(Berlin, 2026, 10, 1); + + var period = Resolve(PeriodPreset.MonthToDate, now); + + // Like the current month of "last 12 months" at the same instant: today exists, and is empty. + Assert.Equal((period.From, period.From), (period.To, period.Now)); + Assert.True(period.IsToDate); + Assert.False(period.HasNotStarted()); + Assert.Equal((Day(2026, 10, 1), Day(2026, 10, 1)), (period.FirstDay, period.LastDay)); + Assert.Equal(Day(2026, 10, 1), period.EffectiveLastDay()); + } + + [Fact] + public void At_the_exact_local_midnight_no_history_is_neither_started_nor_waiting_to_start() + { + var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, At(Berlin, 2026, 10, 1), Berlin); + + Assert.True(period.HasNoHistory()); + Assert.False(period.HasNotStarted()); + } + + [Theory] + [InlineData(null, "2026-09-30")] + [InlineData("2026-10-01", null)] + [InlineData("2026-09-30", "2026-09-01")] + [InlineData("1899-12-31", "2026-01-01")] + [InlineData("2026-01-01", "2300-01-01")] + public void A_custom_range_that_is_missing_reversed_or_out_of_bounds_is_rejected(string? first, string? last) + { + DateOnly? from = first is null ? null : Iso(first); + DateOnly? to = last is null ? null : Iso(last); + + Assert.False(PeriodResolver.IsValidCustomRange(from, to)); + Assert.Throws(() => + PeriodResolver.Resolve(PeriodPreset.Custom, from, to, At(Berlin, 2026, 9, 19, 14, 37), Berlin)); + } + + [Fact] + public void All_history_spans_the_available_data_and_stops_at_its_last_day_when_that_is_in_the_past() + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(1997, 1, 1), Day(2026, 5, 31)); + + Assert.Equal(Day(1997, 1, 1), period.FirstDay); + Assert.Equal(Day(2026, 5, 31), period.LastDay); + Assert.Equal(Utc(1996, 12, 31, 23), period.From); + Assert.Equal(Utc(2026, 5, 31, 22), period.To); + Assert.False(period.IsToDate); + } + + [Fact] + public void All_history_of_live_data_runs_to_now() + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var open = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1)); + var toToday = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1), Day(2026, 9, 19)); + + Assert.Equal(now, open.To); + Assert.True(open.IsToDate); + Assert.Equal(Day(2026, 9, 19), open.LastDay); + Assert.Equal(open, toToday); + } + + [Fact] + public void All_history_without_any_available_data_is_an_explicit_no_history_result() + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin); + + Assert.True(period.HasNoHistory()); + Assert.Equal(PeriodPreset.AllHistory, period.Preset); + Assert.Equal(Day(2026, 9, 19), period.FirstDay); + Assert.True(period.LastDay < period.FirstDay); + Assert.Equal(period.From, period.To); + Assert.Equal(Utc(2026, 9, 18, 22), period.From); + + // "No history" is not "not yet occurred", and nothing is to date. + Assert.False(period.NotYetOccurred); + Assert.False(period.IsToDate); + Assert.False(period.ExtendsPastNow); + } + + [Fact] + public void Availability_whose_last_day_precedes_its_first_is_treated_as_no_history() + { + var period = PeriodResolver.Resolve( + PeriodPreset.AllHistory, null, null, At(Berlin, 2026, 9, 19, 14, 37), Berlin, Day(2026, 5, 1), Day(2026, 4, 30)); + + Assert.True(period.HasNoHistory()); + } + + [Theory] + [InlineData("0001-01-01")] + [InlineData("0206-05-01")] + [InlineData("1899-12-31")] + public void All_history_reads_a_stray_reading_date_before_1900_as_1900_instead_of_throwing(string strayFirst) + { + // Availability comes from the data, not from a validated URL token: year 1 used to throw from the + // local-midnight arithmetic in Berlin, and year 206 spanned 1,821 years. + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Iso(strayFirst)); + + Assert.Equal(PeriodResolver.MinSupportedDate, period.FirstDay); + Assert.Equal(Day(2026, 9, 19), period.LastDay); + Assert.Equal(now, period.To); + } + + [Fact] + public void All_history_reads_availability_after_today_as_today_because_actuals_stop_at_now() + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var future = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1), Day(2027, 3, 1)); + var onlyFuture = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2027, 1, 1)); + + Assert.Equal(PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1)), future); + Assert.True(future.IsToDate); + Assert.False(future.ExtendsPastNow); + Assert.True(onlyFuture.HasNoHistory()); + } + + [Fact] + public void All_history_is_open_ended_so_rows_recorded_far_after_now_are_still_looked_for() + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var live = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1)); + var historical = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(1997, 1, 1), Day(2026, 5, 31)); + + // A Tasmota row stamped in 2027 must reach the "recorded after now" block (D-04): no upper bound. + Assert.Null(live.NominalEnd()); + Assert.Null(historical.NominalEnd()); + Assert.Equal(Day(2026, 9, 19), live.NominalLastDay()); + + // Every other preset names where it ends. + Assert.Equal(Utc(2026, 12, 31, 23), Resolve(PeriodPreset.YearToDate, now).NominalEnd()); + Assert.Equal(Utc(2026, 8, 31, 22), Resolve(PeriodPreset.LastMonth, now).NominalEnd()); + } + + [Fact] + public void Presets_ignore_custom_dates_and_custom_ignores_availability() + { + var now = At(Berlin, 2026, 9, 19, 14, 37); + + var month = PeriodPreset.MonthToDate; + Assert.Equal( + PeriodResolver.Resolve(month, null, null, now, Berlin), + PeriodResolver.Resolve(month, Day(2020, 1, 1), Day(2020, 1, 31), now, Berlin, Day(1997, 1, 1))); + + var custom = PeriodResolver.Resolve(PeriodPreset.Custom, Day(2026, 6, 1), Day(2026, 6, 30), now, Berlin, Day(1997, 1, 1), Day(1998, 1, 1)); + Assert.Equal(Day(2026, 6, 1), custom.FirstDay); + } +} diff --git a/tests/Core.Tests/Analysis/ProvenanceRulesTests.cs b/tests/Core.Tests/Analysis/ProvenanceRulesTests.cs new file mode 100644 index 0000000..ce42ad1 --- /dev/null +++ b/tests/Core.Tests/Analysis/ProvenanceRulesTests.cs @@ -0,0 +1,62 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// Provenance is its own dimension (D-14): which qualities a bucket's amount rests on, whatever its status. +/// +public sealed class ProvenanceRulesTests +{ + [Fact] + public void Each_quality_with_an_amount_sets_its_own_flag() + { + Assert.Equal(Provenance.Measured, ProvenanceRules.ProvenanceOf(1, 0, 0, 0, false, false)); + Assert.Equal(Provenance.Manual, ProvenanceRules.ProvenanceOf(0, 2, 0, 0, false, false)); + Assert.Equal(Provenance.Imported, ProvenanceRules.ProvenanceOf(0, 0, 3, 0, false, false)); + Assert.Equal(Provenance.Estimated, ProvenanceRules.ProvenanceOf(0, 0, 0, 4, false, false)); + } + + [Fact] + public void A_bucket_mixing_imported_months_and_divided_shares_is_both_imported_and_estimated() + { + var provenance = ProvenanceRules.ProvenanceOf(0, 0, 14, 2.5, false, false); + + Assert.Equal(Provenance.Imported | Provenance.Estimated, provenance); + } + + [Fact] + public void A_negative_amount_is_still_a_contribution_because_savings_and_balances_are_signed() + { + Assert.Equal(Provenance.Measured, ProvenanceRules.ProvenanceOf(-5, 0, 0, 0, false, false)); + } + + [Fact] + public void Float_noise_left_by_dividing_and_resumming_does_not_count_as_a_contribution() + { + Assert.Equal(Provenance.Manual, ProvenanceRules.ProvenanceOf(1e-12, 7, -1e-10, 0, false, false)); + } + + [Fact] + public void Opening_balance_and_derived_are_flags_of_their_own() + { + Assert.Equal( + Provenance.Measured | Provenance.OpeningBalance | Provenance.Derived, + ProvenanceRules.ProvenanceOf(3, 0, 0, 0, openingBalance: true, derived: true)); + } + + [Fact] + public void A_true_zero_has_no_provenance() + { + Assert.Equal(Provenance.None, ProvenanceRules.ProvenanceOf(0, 0, 0, 0, false, false)); + } + + [Fact] + public void A_derived_value_keeps_everything_its_sources_rest_on() + { + var derived = ProvenanceRules.Derive([Provenance.Measured, Provenance.Imported | Provenance.Estimated]); + + Assert.Equal(Provenance.Derived | Provenance.Measured | Provenance.Imported | Provenance.Estimated, derived); + Assert.Equal(Provenance.Derived, ProvenanceRules.Derive([])); + } +} diff --git a/tests/Core.Tests/Analysis/ReaderSupportTests.cs b/tests/Core.Tests/Analysis/ReaderSupportTests.cs new file mode 100644 index 0000000..7a50769 --- /dev/null +++ b/tests/Core.Tests/Analysis/ReaderSupportTests.cs @@ -0,0 +1,219 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Core.Analysis.Totals; +using static MeterVault.Core.Tests.Analysis.CoverageTestData; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// The pure pieces the Infrastructure reader rests on: a period as one bucket, lifecycle zeros (D-24), available +/// ranges (D-19) and the sum of a measure's members (D-22). +/// +public sealed class ReaderSupportTests +{ + private static readonly DateTimeOffset Now = At(2026, 9, 19, 14, 37); + + [Theory] + [InlineData(2025, 1, 1, 2025, 12, 31, BucketSize.Year)] + [InlineData(2024, 1, 1, 2025, 12, 31, BucketSize.Year)] + [InlineData(2026, 2, 1, 2026, 2, 28, BucketSize.Month)] + [InlineData(2025, 10, 1, 2026, 9, 30, BucketSize.Month)] + [InlineData(2026, 9, 14, 2026, 9, 20, BucketSize.Week)] + [InlineData(2026, 9, 15, 2026, 9, 21, BucketSize.Day)] + [InlineData(2026, 1, 15, 2026, 3, 31, BucketSize.Day)] + public void A_period_is_the_coarsest_unit_it_is_made_of(int y1, int m1, int d1, int y2, int m2, int d2, BucketSize expected) => + Assert.Equal(expected, PeriodBucket.AlignedSize(new DateOnly(y1, m1, d1), new DateOnly(y2, m2, d2))); + + [Fact] + public void A_year_to_date_is_a_year_cut_at_now() + { + var period = PeriodResolver.Resolve(PeriodPreset.YearToDate, null, null, Now, Berlin); + + var bucket = PeriodBucket.Of(period); + + Assert.Equal(BucketSize.Year, bucket.Size); + Assert.Equal(new DateOnly(2026, 1, 1), bucket.FirstDay); + Assert.Equal(new DateOnly(2026, 9, 20), bucket.EndDay); + Assert.Equal(period.From, bucket.From); + Assert.Equal(Now, bucket.To); + Assert.Equal(new DateOnly(2027, 1, 1), bucket.NominalEndDay); + } + + [Fact] + public void A_monthly_source_resolves_the_month_as_a_period_although_not_its_days() + { + // The reason totals get their own status: last month by day is unresolved day by day, resolved as a month. + var period = PeriodResolver.Resolve(PeriodPreset.LastMonth, null, null, Now, Berlin); + var run = new CoverageRun(Midnight(new DateOnly(2026, 8, 1)), Midnight(new DateOnly(2026, 9, 1)), ResolutionClass.Month, DividedAtMonths: true); + + var total = CoverageEvaluator.Evaluate(PeriodBucket.Of(period), [run], Berlin, openingBalanceInBucket: false, Now); + var day = CoverageEvaluator.Evaluate(Day(2026, 8, 10), [run], Berlin, openingBalanceInBucket: false, Now); + + Assert.Equal(BucketStatus.Available, total.Status); + Assert.Equal(BucketStatus.Unresolved, day.Status); + } + + [Fact] + public void A_period_that_has_not_started_is_an_empty_bucket() + { + var period = PeriodResolver.Resolve(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), Now, Berlin); + + var bucket = PeriodBucket.Of(period); + + Assert.Equal(bucket.From, bucket.To); + Assert.Equal(bucket.FirstDay, bucket.EndDay); + } + + [Fact] + public void Time_outside_the_service_period_is_known_zero_coverage() + { + var runs = LifecycleCoverage.OutsideService(new DateOnly(2026, 2, 10), new DateOnly(2026, 6, 30), Berlin); + + Assert.Equal(2, runs.Count); + Assert.Equal(Midnight(new DateOnly(2026, 2, 10)), runs[0].To); + Assert.Equal(Midnight(new DateOnly(2026, 7, 1)), runs[1].From); + Assert.All(runs, r => Assert.False(r.IsGap)); + Assert.All(runs, r => Assert.Equal(ResolutionClass.Hour, r.Resolution)); + Assert.Empty(LifecycleCoverage.OutsideService(null, null, Berlin)); + } + + [Fact] + public void A_meter_installed_mid_month_contributes_a_whole_month() + { + // Installed 10 February with daily data from then: as its own series February is partial, as a member of a + // total the days before installation are a known zero and February is complete (D-24). + var data = new CoverageRun(Midnight(new DateOnly(2026, 2, 10)), Midnight(new DateOnly(2026, 3, 1)), ResolutionClass.Day, DividedAtMonths: true); + var february = Month(2026, 2); + + var own = CoverageEvaluator.Evaluate(february, [data], Berlin, false, Now); + var contributing = CoverageEvaluator.Evaluate( + february, LifecycleCoverage.WithService([data], new DateOnly(2026, 2, 10), null, Berlin), Berlin, false, Now); + + Assert.Equal(BucketStatus.Partial, own.Status); + Assert.Equal(BucketStatus.Available, contributing.Status); + } + + [Fact] + public void A_retired_meters_zero_is_known_only_up_to_now() + { + var runs = LifecycleCoverage.OutsideService(null, new DateOnly(2026, 6, 30), Berlin); + + var capped = CoverageRuns.CapAt(runs, Now, Berlin); + + Assert.Equal(Now, Assert.Single(capped).To); + } + + [Fact] + public void Available_range_is_capped_at_now_and_names_its_latest_month() + { + // A current-month label row covers September but closes after now: availability ends where it starts. + var runs = new[] + { + new CoverageRun(Midnight(new DateOnly(2025, 11, 1)), Midnight(new DateOnly(2026, 10, 1)), ResolutionClass.Month, true, + LastIntervalStart: Midnight(new DateOnly(2026, 9, 1))), + }; + + var range = AvailableRange.OfRuns(runs, Now, Berlin); + + Assert.NotNull(range); + Assert.Equal(new DateOnly(2025, 11, 1), range.FirstDay); + Assert.Equal(new DateOnly(2026, 8, 31), range.LastDay); + Assert.Equal(new DateOnly(2026, 8, 1), range.LatestMonth); + Assert.Null(AvailableRange.OfRuns([], Now, Berlin)); + } + + [Fact] + public void Available_ranges_unite_to_their_outer_bounds() + { + var early = AvailableRange.Of(Midnight(new DateOnly(2022, 11, 1)), Midnight(new DateOnly(2023, 1, 1)), Berlin); + var late = AvailableRange.Of(Midnight(new DateOnly(2026, 5, 1)), Midnight(new DateOnly(2026, 6, 1)), Berlin); + + var union = AvailableRange.Union([early, null, late], Berlin); + + Assert.NotNull(union); + Assert.Equal(new DateOnly(2022, 11, 1), union.FirstDay); + Assert.Equal(new DateOnly(2026, 5, 31), union.LastDay); + Assert.Equal(new DateOnly(2026, 5, 1), union.LatestMonth); + Assert.Null(AvailableRange.Union([null], Berlin)); + } + + [Fact] + public void Measure_members_add_up_when_all_are_available() + { + var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (2, BucketValue.Available(150, Provenance.Imported))]); + + Assert.Equal(BucketStatus.Available, sum.Status); + Assert.Equal(250, sum.Value); + Assert.Equal(Provenance.Measured | Provenance.Imported, sum.Provenance); + } + + [Fact] + public void A_missing_member_makes_the_total_partial_and_names_it() + { + var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (2, BucketValue.Missing())]); + + Assert.Equal(BucketStatus.Partial, sum.Status); + Assert.Equal(100, sum.Value); + Assert.Equal(ValueIssue.MissingSource, sum.Issue); + Assert.Equal([2], sum.DependencyPath); + } + + [Fact] + public void Without_any_member_data_the_total_is_missing() + { + var sum = MeasureValues.Sum([(1, BucketValue.Missing()), (2, BucketValue.Missing())]); + + Assert.Equal(BucketStatus.Missing, sum.Status); + Assert.Null(sum.Value); + } + + [Theory] + [InlineData(BucketStatus.Pending, ValueIssue.AnalysisPending)] + [InlineData(BucketStatus.Invalid, ValueIssue.NonFinite)] + [InlineData(BucketStatus.Unresolved, ValueIssue.CoarseResolution)] + public void A_spoiling_member_decides_the_total(BucketStatus status, ValueIssue issue) + { + var spoiling = new BucketValue(null, status, Provenance.None, issue, null, [7, 3]); + + var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (7, spoiling), (2, BucketValue.Missing())]); + + Assert.Equal(status, sum.Status); + Assert.Null(sum.Value); + Assert.Equal(issue, sum.Issue); + Assert.Equal([7, 3], sum.DependencyPath); + } + + [Fact] + public void A_partial_member_keeps_its_issue_and_signed_values_stay_signed() + { + var partial = new BucketValue(-40, BucketStatus.Partial, Provenance.Derived, ValueIssue.OpeningBalance); + + var sum = MeasureValues.Sum([(1, BucketValue.Available(-10, Provenance.Measured)), (5, partial)]); + + Assert.Equal(BucketStatus.Partial, sum.Status); + Assert.Equal(-50, sum.Value); + Assert.Equal(ValueIssue.OpeningBalance, sum.Issue); + Assert.Equal([5], sum.DependencyPath); + } + + [Fact] + public void A_series_of_members_is_summed_bucket_by_bucket() + { + IReadOnlyList a = [BucketValue.Available(100, Provenance.Measured), BucketValue.Available(80, Provenance.Measured)]; + IReadOnlyList b = [BucketValue.Available(150, Provenance.Measured), BucketValue.Missing()]; + + var series = MeasureValues.SumSeries([(1, a), (2, b)], 2); + + Assert.Equal(250, series[0].Value); + Assert.Equal(BucketStatus.Available, series[0].Status); + Assert.Equal(80, series[1].Value); + Assert.Equal(BucketStatus.Partial, series[1].Status); + Assert.Throws(() => MeasureValues.SumSeries([(1, a)], 3)); + } + + private static AnalysisBucket Month(int year, int month) + { + var first = new DateOnly(year, month, 1); + return new AnalysisBucket(first, first.AddMonths(1), Midnight(first), Midnight(first.AddMonths(1)), BucketSize.Month); + } +} diff --git a/tests/Core.Tests/Analysis/ResolutionClassifierTests.cs b/tests/Core.Tests/Analysis/ResolutionClassifierTests.cs new file mode 100644 index 0000000..1ce2966 --- /dev/null +++ b/tests/Core.Tests/Analysis/ResolutionClassifierTests.cs @@ -0,0 +1,101 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using static MeterVault.Core.Tests.Analysis.AnalysisTestTime; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// The resolution classes of D-13, at their exact limits: an hour and a minute, 25 hours, seven days and +/// an hour, 31 days and two hours. The slack is what keeps real intervals — a late poll, a DST day, a +/// month with a DST hour — in the class they belong to. There is one classifier (A-09); the bucket side of +/// the same scale — which class a bucket size needs — is pinned here too. +/// +public sealed class ResolutionClassifierTests +{ + public static TheoryData Limits => new() + { + { TimeSpan.Zero, ResolutionClass.Hour }, + { TimeSpan.FromMinutes(5), ResolutionClass.Hour }, + { TimeSpan.FromMinutes(60), ResolutionClass.Hour }, + { TimeSpan.FromMinutes(61), ResolutionClass.Hour }, + { TimeSpan.FromMinutes(61) + TimeSpan.FromTicks(1), ResolutionClass.Day }, + { TimeSpan.FromHours(23), ResolutionClass.Day }, + { TimeSpan.FromHours(25), ResolutionClass.Day }, + { TimeSpan.FromHours(25) + TimeSpan.FromSeconds(1), ResolutionClass.Week }, + { TimeSpan.FromDays(7) + TimeSpan.FromHours(1), ResolutionClass.Week }, + { TimeSpan.FromDays(7) + TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1), ResolutionClass.Month }, + { TimeSpan.FromDays(31) + TimeSpan.FromHours(1), ResolutionClass.Month }, + { TimeSpan.FromDays(31) + TimeSpan.FromHours(2), ResolutionClass.Month }, + { TimeSpan.FromDays(31) + TimeSpan.FromHours(2) + TimeSpan.FromSeconds(1), ResolutionClass.Coarse }, + { TimeSpan.FromDays(365 * 12), ResolutionClass.Coarse }, + }; + + [Theory] + [MemberData(nameof(Limits))] + public void An_interval_is_classified_by_its_length_up_to_each_limit_inclusive(TimeSpan length, ResolutionClass expected) + { + Assert.Equal(expected, ResolutionClassifier.Classify(length)); + } + + [Fact] + public void A_negative_length_claims_no_time_and_is_the_finest_class() + { + Assert.Equal(ResolutionClass.Hour, ResolutionClassifier.Classify(TimeSpan.FromHours(-3))); + } + + [Fact] + public void The_long_autumn_day_is_still_a_day_and_october_with_its_extra_hour_still_a_month() + { + Assert.Equal(TimeSpan.FromHours(25), InBerlin(2026, 10, 26) - InBerlin(2026, 10, 25)); + Assert.Equal(ResolutionClass.Day, ResolutionClassifier.Classify(InBerlin(2026, 10, 25), InBerlin(2026, 10, 26))); + Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2026, 10, 1), InBerlin(2026, 11, 1))); + Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2027, 2, 1), InBerlin(2027, 3, 1))); + } + + [Fact] + public void A_week_read_an_hour_late_is_still_a_week_but_two_weeks_are_not() + { + Assert.Equal(ResolutionClass.Week, ResolutionClassifier.Classify(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 14, 11))); + Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 21, 10))); + } + + [Theory] + [InlineData(ResolutionClass.Hour, 61)] + [InlineData(ResolutionClass.Day, 25 * 60)] + [InlineData(ResolutionClass.Week, (7 * 24 * 60) + 60)] + [InlineData(ResolutionClass.Month, (31 * 24 * 60) + 120)] + public void Each_class_admits_intervals_up_to_its_own_limit(ResolutionClass resolution, int minutes) + { + Assert.Equal(TimeSpan.FromMinutes(minutes), ResolutionClassifier.LimitOf(resolution)); + Assert.Equal(resolution, ResolutionClassifier.Classify(ResolutionClassifier.LimitOf(resolution))); + } + + [Fact] + public void Data_coarser_than_a_month_has_no_limit() + { + Assert.Equal(TimeSpan.MaxValue, ResolutionClassifier.LimitOf(ResolutionClass.Coarse)); + } + + [Theory] + [InlineData(BucketSize.Day, ResolutionClass.Day)] + [InlineData(BucketSize.Week, ResolutionClass.Week)] + [InlineData(BucketSize.Month, ResolutionClass.Month)] + [InlineData(BucketSize.Year, ResolutionClass.Month)] + public void A_bucket_size_names_the_coarsest_class_that_resolves_it_outright(BucketSize size, ResolutionClass expected) + { + Assert.Equal(expected, ResolutionClassifier.CoarsestResolving(size)); + } + + [Fact] + public void Auto_has_no_class_because_it_is_resolved_before_any_bucket_exists() + { + Assert.Throws(() => ResolutionClassifier.CoarsestResolving(BucketSize.Auto)); + } + + [Fact] + public void The_coarsest_of_two_classes_is_the_resolution_of_a_combined_value() + { + Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Coarsest(ResolutionClass.Hour, ResolutionClass.Month)); + Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.Coarsest(ResolutionClass.Coarse, ResolutionClass.Day)); + } +} diff --git a/tests/Core.Tests/Analysis/RollupBuilderTests.cs b/tests/Core.Tests/Analysis/RollupBuilderTests.cs new file mode 100644 index 0000000..2c2b227 --- /dev/null +++ b/tests/Core.Tests/Analysis/RollupBuilderTests.cs @@ -0,0 +1,278 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Rollups; +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; +using static MeterVault.Core.Tests.Analysis.AnalysisTestTime; +using static MeterVault.Core.Tests.TestData; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// Day and month rollups (D-12): the buckets analysis reads instead of consumption. A rollup must sum exactly the +/// rows filed in its local day or month, keep the amount's provenance by quality, and carry the markers the +/// coverage evaluator and the recorded-after-now check need (opening balance, divided, latest interval end). +/// +public sealed class RollupBuilderTests +{ + private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); + + private static Consumption Row( + DateTimeOffset time, + double amount, + ReadingQuality quality = ReadingQuality.Measured, + ConsumptionKind kind = ConsumptionKind.Consumption, + DateTimeOffset? intervalEnd = null, + bool divided = false, + bool openingBalance = false) => new() + { + MeterId = 1, + Time = time.ToUniversalTime(), + Amount = amount, + Quality = quality, + Kind = kind, + IntervalEnd = intervalEnd?.ToUniversalTime(), + Divided = divided, + OpeningBalance = openingBalance, + }; + + private IReadOnlyList Normalize( + MeterMode mode, TimeZoneInfo zone, IReadOnlyList readings, DateOnly? installedAt = null) => + _engine.Normalize(new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "kWh", InstalledAt = installedAt }, + Readings = readings, + Events = [], + TimeZone = zone, + }); + + private static Reading Measured(DateTimeOffset time, double value) => + new() { MeterId = 1, Time = time.ToUniversalTime(), Value = value, Quality = ReadingQuality.Measured }; + + [Fact] + public void No_rows_give_no_buckets() + { + var rollups = RollupBuilder.Build([], Berlin); + + Assert.Empty(rollups.Days); + Assert.Empty(rollups.Months); + } + + [Fact] + public void A_row_is_filed_under_the_local_day_and_month_of_its_stamp() + { + // 23:30 UTC on 31 January is 00:30 on 1 February in Berlin: February, not January. + var rollups = RollupBuilder.Build([Row(Utc(2026, 1, 31, 23, 30), 5), Row(Utc(2026, 1, 31, 22, 30), 3)], Berlin); + + Assert.Equal([new DateOnly(2026, 1, 31), new DateOnly(2026, 2, 1)], rollups.Days.Select(d => d.Start)); + Assert.Equal([3d, 5d], rollups.Days.Select(d => d.Amount)); + Assert.Equal([new DateOnly(2026, 1, 1), new DateOnly(2026, 2, 1)], rollups.Months.Select(m => m.Start)); + Assert.Equal([3d, 5d], rollups.Months.Select(m => m.Amount)); + } + + [Fact] + public void The_same_rows_land_in_other_days_in_another_zone() + { + var rows = new[] { Row(Utc(2026, 1, 31, 23, 30), 5) }; + + Assert.Equal(new DateOnly(2026, 1, 31), Assert.Single(RollupBuilder.Build(rows, TimeZoneInfo.Utc).Days).Start); + Assert.Equal(new DateOnly(2026, 1, 31), Assert.Single(RollupBuilder.Build(rows, NewYork).Days).Start); + Assert.Equal(new DateOnly(2026, 2, 1), Assert.Single(RollupBuilder.Build(rows, Berlin).Days).Start); + } + + [Fact] + public void Amounts_are_split_by_quality_and_estimated_takes_interpolated_too() + { + var day = InBerlin(2026, 3, 10, 12); + var rollups = RollupBuilder.Build( + [ + Row(day, 1, ReadingQuality.Measured), + Row(day.AddMinutes(1), 2, ReadingQuality.Manual), + Row(day.AddMinutes(2), 4, ReadingQuality.Imported), + Row(day.AddMinutes(3), 8, ReadingQuality.Estimated), + Row(day.AddMinutes(4), 16, ReadingQuality.Interpolated), + Row(day.AddMinutes(5), -0.5, ReadingQuality.Measured), + ], Berlin); + + var bucket = Assert.Single(rollups.Days); + Assert.Equal(30.5, bucket.Amount); + Assert.Equal(0.5, bucket.Measured); + Assert.Equal(2, bucket.Manual); + Assert.Equal(4, bucket.Imported); + Assert.Equal(24, bucket.Estimated); + Assert.Equal(6, bucket.Rows); + Assert.Equal(RollupFlags.None, bucket.Flags); + Assert.Equal(Provenance.Measured | Provenance.Manual | Provenance.Imported | Provenance.Estimated, bucket.Provenance); + } + + [Fact] + public void Kinds_are_separate_buckets_of_the_same_day() + { + var noon = InBerlin(2026, 6, 1, 12); + var rollups = RollupBuilder.Build( + [ + Row(noon, 7, kind: ConsumptionKind.Generation), + Row(noon, 3, kind: ConsumptionKind.Consumption), + ], Berlin); + + Assert.Equal( + [(ConsumptionKind.Consumption, 3d), (ConsumptionKind.Generation, 7d)], + rollups.Days.Select(d => (d.Kind, d.Amount))); + Assert.Equal(2, rollups.Months.Count); + } + + [Fact] + public void A_bucket_is_flagged_when_any_of_its_rows_is_an_opening_balance_or_divided() + { + var rollups = RollupBuilder.Build( + [ + Row(InBerlin(2026, 4, 1, 8), 700, openingBalance: true), + Row(InBerlin(2026, 4, 1, 9), 1), + Row(InBerlin(2026, 4, 30, 23, 59, 59), 20, ReadingQuality.Estimated, divided: true), + Row(InBerlin(2026, 5, 2, 9), 1), + ], Berlin); + + Assert.Equal( + [RollupFlags.OpeningBalance, RollupFlags.Divided, RollupFlags.None], + rollups.Days.Select(d => d.Flags)); + Assert.Equal([RollupFlags.OpeningBalance | RollupFlags.Divided, RollupFlags.None], rollups.Months.Select(m => m.Flags)); + Assert.True(rollups.Days[0].HasOpeningBalance); + Assert.True(rollups.Days[0].Provenance.HasFlag(Provenance.OpeningBalance)); + } + + [Fact] + public void The_latest_interval_end_falls_back_to_the_stamp_for_rows_without_an_interval() + { + var stamp = InBerlin(2026, 7, 3, 10); + var rollups = RollupBuilder.Build( + [ + Row(stamp, 1, intervalEnd: stamp.AddHours(2)), + Row(stamp.AddHours(1), 1), + ], Berlin); + + Assert.Equal(stamp.AddHours(2).ToUniversalTime(), Assert.Single(rollups.Days).MaxIntervalEnd); + Assert.Equal(TimeSpan.Zero, rollups.Days[0].MaxIntervalEnd.Offset); + + var bare = RollupBuilder.Build([Row(stamp, 1)], Berlin); + Assert.Equal(stamp.ToUniversalTime(), Assert.Single(bare.Days).MaxIntervalEnd); + } + + [Theory] + [InlineData("UTC")] + [InlineData("Europe/Berlin")] + [InlineData("America/New_York")] + public void A_monthly_sheet_row_is_its_month_and_ends_where_the_month_ends(string zoneId) + { + // A-05: "Mai 2026" is the register at the end of May. Its row is stamped inside May, and it is recorded + // up to the local midnight that ends May — a reader on 20 May must not count it as an actual. + var zone = zoneId == "UTC" ? TimeZoneInfo.Utc : TimeZoneInfo.FindSystemTimeZoneById(zoneId); + var readings = Enumerable.Range(0, 4).Select(i => Reading(1, Month(2026, 3).AddMonths(i), 100 + (10 * i))).ToList(); + + var rollups = RollupBuilder.Build(Normalize(MeterMode.CumulativeCounter, zone, readings), zone); + + Assert.Equal( + [new DateOnly(2026, 3, 1), new DateOnly(2026, 4, 1), new DateOnly(2026, 5, 1), new DateOnly(2026, 6, 1)], + rollups.Months.Select(m => m.Start)); + Assert.Equal([100d, 10d, 10d, 10d], rollups.Months.Select(m => m.Amount)); + Assert.All(rollups.Months, m => Assert.Equal(m.Amount, m.Imported)); + Assert.Equal( + GapAttribution.LocalMidnight(new DateOnly(2026, 6, 1), zone), + rollups.Months.Single(m => m.Start == new DateOnly(2026, 5, 1)).MaxIntervalEnd); + + // One row each, filed on the 1st of its month: the day table agrees with the month table. + Assert.Equal(rollups.Months.Select(m => (m.Start, m.Amount)), rollups.Days.Select(d => (d.Start, d.Amount))); + } + + [Fact] + public void A_first_reading_without_a_start_flags_its_day_but_an_install_date_does_not() + { + var readings = new[] { Measured(InBerlin(2026, 8, 3, 9), 700), Measured(InBerlin(2026, 8, 4, 9), 710) }; + + var unknown = RollupBuilder.Build(Normalize(MeterMode.CumulativeCounter, Berlin, readings), Berlin); + Assert.Equal([RollupFlags.OpeningBalance, RollupFlags.None], unknown.Days.Select(d => d.Flags)); + Assert.Equal(RollupFlags.OpeningBalance, Assert.Single(unknown.Months).Flags); + + var installed = RollupBuilder.Build( + Normalize(MeterMode.CumulativeCounter, Berlin, readings, installedAt: new DateOnly(2026, 7, 1)), Berlin); + Assert.All(installed.Days, d => Assert.Equal(RollupFlags.None, d.Flags)); + Assert.Equal(710, installed.Months.Sum(m => m.Amount)); + } + + [Fact] + public void An_interval_across_a_month_boundary_is_divided_between_both_months() + { + var august1 = InBerlin(2026, 8, 1, 9); + var september16 = InBerlin(2026, 9, 16, 18); + var rows = Normalize(MeterMode.CumulativeCounter, Berlin, [Measured(august1, 700), Measured(september16, 746)]); + + var rollups = RollupBuilder.Build(rows, Berlin); + + var august = rollups.Months.Single(m => m.Start == new DateOnly(2026, 8, 1)); + var september = rollups.Months.Single(m => m.Start == new DateOnly(2026, 9, 1)); + var augustShare = 46 * ((InBerlin(2026, 9, 1) - august1) / (september16 - august1)); + + Assert.Equal(700 + augustShare, august.Amount, 9); + Assert.Equal(46 - augustShare, september.Amount, 9); + // The first reading is measured; the shares are estimates, divided at 1 September. + Assert.Equal(700, august.Measured, 9); + Assert.Equal(augustShare, august.Estimated, 9); + Assert.Equal(46 - augustShare, september.Estimated, 9); + Assert.True(august.Flags.HasFlag(RollupFlags.Divided)); + Assert.True(september.Flags.HasFlag(RollupFlags.Divided)); + // August's share is stamped at its last second, and its interval ends at the start of September. + Assert.Equal(InBerlin(2026, 9, 1).ToUniversalTime(), august.MaxIntervalEnd); + Assert.Equal(new DateOnly(2026, 8, 31), rollups.Days.Single(d => d.Estimated != 0 && d.Start.Month == 8).Start); + } + + [Fact] + public void Daily_snapshots_at_local_midnight_are_booked_in_the_day_they_close() + { + // D-11: the 00:00 reading on 11 March closes 10 March. + var readings = Enumerable.Range(0, 4) + .Select(i => Measured(InBerlin(2026, 3, 10).AddDays(i), 100 + (i * i))) + .ToList(); + + var rollups = RollupBuilder.Build(Normalize(MeterMode.CumulativeCounter, Berlin, readings), Berlin); + + Assert.Equal( + [(new DateOnly(2026, 3, 10), 101d), (new DateOnly(2026, 3, 11), 3d), (new DateOnly(2026, 3, 12), 5d)], + rollups.Days.Select(d => (d.Start, d.Amount))); + Assert.Equal(InBerlin(2026, 3, 12).ToUniversalTime(), rollups.Days[1].MaxIntervalEnd); + } + + [Fact] + public void Days_and_months_both_sum_to_the_rows_and_do_not_depend_on_row_order() + { + var start = InBerlin(2026, 1, 1, 6); + // Hourly for 75 days, across two month ends and the spring DST change; uneven increments, never falling. + var readings = Enumerable.Range(0, 24 * 75) + .Select(i => Measured(start.AddHours(i), 1000 + (i * 0.77) + (0.5 * Math.Sin(i)))) + .ToList(); + var rows = Normalize(MeterMode.CumulativeCounter, Berlin, readings); + + var rollups = RollupBuilder.Build(rows, Berlin); + var shuffled = RollupBuilder.Build(rows.Reverse().ToList(), Berlin); + + Assert.Equal(rows.Sum(r => r.Amount), rollups.Days.Sum(d => d.Amount), 6); + Assert.Equal(rows.Sum(r => r.Amount), rollups.Months.Sum(m => m.Amount), 6); + Assert.Equal(rows.Count, rollups.Days.Sum(d => d.Rows)); + Assert.Equal(rows.Count, rollups.Months.Sum(m => m.Rows)); + foreach (var month in rollups.Months) + { + var days = rollups.Days.Where(d => RollupBuilder.MonthOf(d.Start) == month.Start).ToList(); + Assert.Equal(month.Amount, days.Sum(d => d.Amount), 6); + Assert.Equal(month.Rows, days.Sum(d => d.Rows)); + } + + // Rebuilding the same rows, in any order, gives bit-identical buckets: a diff write touches nothing. + Assert.Equal(rollups.Days, shuffled.Days); + Assert.Equal(rollups.Months, shuffled.Months); + } + + [Fact] + public void Local_day_and_month_helpers_follow_the_zone() + { + Assert.Equal(new DateOnly(2026, 3, 29), RollupBuilder.LocalDay(Utc(2026, 3, 28, 23, 30), Berlin)); + Assert.Equal(new DateOnly(2026, 11, 30), RollupBuilder.LocalDay(Utc(2026, 12, 1, 3), NewYork)); + Assert.Equal(new DateOnly(2026, 2, 1), RollupBuilder.MonthOf(new DateOnly(2026, 2, 28))); + } +} diff --git a/tests/Core.Tests/Analysis/SeparatelyBilledSubmeterTests.cs b/tests/Core.Tests/Analysis/SeparatelyBilledSubmeterTests.cs new file mode 100644 index 0000000..961726a --- /dev/null +++ b/tests/Core.Tests/Analysis/SeparatelyBilledSubmeterTests.cs @@ -0,0 +1,260 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using static MeterVault.Core.Tests.Analysis.TotalsSeed; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// D-35: a heat-pump meter in cascade is a subsection of the house for quantities, but the supplier bills it at +/// its own tariff. Main 300 kWh at 0.30 and heat pump 100 kWh at 0.22 must bill 200 × 0.30 + 100 × 0.22 — so the +/// policy has to say which meter is priced separately and which billed quantity it comes out of, without moving +/// anything in the quantity totals. +/// +public sealed class SeparatelyBilledSubmeterTests +{ + private const int HeatPump = 10; + + [Fact] + public void A_heat_pump_below_Haus_with_its_own_price_is_billed_separately_out_of_the_grid_import() + { + var meters = Meters(); + meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], id => id == HeatPump); + + var billing = result.ForType(Electricity).Billing; + Assert.Equal([Netz], billing.BilledMeterIds); + Assert.Equal([new SeparatelyBilledMeter(HeatPump, Netz, Haus)], billing.SeparatelyBilled); + Assert.True(result.IsBilled(HeatPump)); + + // The priceable form: Netz minus the heat pump at the normal price, the heat pump at its own. + Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(Netz)!.Deductions); + Assert.Equal(BillLineKind.UnitPrice, result.LineOf(Netz)!.Kind); + Assert.Equal(BillLineKind.OwnPrice, result.LineOf(HeatPump)!.Kind); + Assert.Empty(result.LineOf(HeatPump)!.Deductions); + Assert.Null(result.LineOf(Haus)); + + // Quantities do not move: the heat pump is still a breakdown of household use. + Assert.Equal(MeterTotalsClass.Breakdown, result.For(HeatPump).Class); + Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + } + + [Fact] + public void Without_its_own_price_a_subsection_stays_inside_its_parent_s_bill() + { + var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Haus); + + Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled); + Assert.False(result.IsBilled(Auto)); + } + + [Fact] + public void A_billed_meter_with_its_own_price_is_simply_billed_not_billed_twice() + { + var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Netz || id == Wasser); + + Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled); + Assert.Empty(result.ForType(Water).Billing.SeparatelyBilled); + } + + [Fact] + public void Where_household_use_is_billed_the_quantity_comes_out_of_the_parent_directly() + { + var meters = Meters(); + meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "m³")); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20)], id => id == 20); + + Assert.Equal([new SeparatelyBilledMeter(20, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled); + } + + [Fact] + public void A_cascade_of_separately_billed_meters_subtracts_each_from_the_one_directly_above() + { + var meters = Meters(); + meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "m³")); + meters.Add(Physical(21, "Pool", Water, MeterMode.CumulativeCounter, "m³")); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20), new(20, 21)], id => id is 20 or 21); + + Assert.Equal( + [new SeparatelyBilledMeter(20, Wasser, null), new SeparatelyBilledMeter(21, 20, null)], + result.ForType(Water).Billing.SeparatelyBilled); + Assert.Equal([new BillDeduction(20, 1)], result.LineOf(Wasser)!.Deductions); + Assert.Equal([new BillDeduction(21, 1)], result.LineOf(20)!.Deductions); + } + + [Fact] + public void An_unlinked_meter_assumed_inside_the_total_load_is_not_billed_separately_and_its_price_is_reported_unused() + { + // Being inside the total load is only an assumption about an unlinked meter; it must not take energy out of + // the grid bill. The price names the gap, and the hint tells the user to link the meter. + var meters = Meters(); + meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); + + var result = TotalsPolicy.Classify(meters, Links(), id => id == HeatPump); + + Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled); + Assert.Empty(result.LineOf(Netz)!.Deductions); + Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, HeatPump), result.Problems); + Assert.Contains(new OverlapHint(OverlapHintKind.NotLinkedBelowTotalLoad, Electricity, HeatPump, Haus), result.Hints); + } + + [Fact] + public void A_subsection_set_to_never_is_not_billed_at_all() + { + var meters = Meters(); + meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh", totals: TotalsOverride.Never)); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], id => id == HeatPump); + + Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled); + Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, HeatPump), result.Problems); + } + + [Fact] + public void A_consumption_root_with_its_own_price_is_not_a_subsection_and_is_not_billed_separately() + { + // No link and no total_load: nothing establishes that the root sits behind the grid import, so D-35 (which is + // about containment children) does not apply. Link it or give the type a total_load meter to bill it. + List meters = + [ + Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport), + Physical(21, "Heat pump", Electricity, MeterMode.CumulativeCounter, "kWh"), + ]; + + var result = TotalsPolicy.Classify(meters, [], id => id == 21); + + Assert.Equal(MeterTotalsClass.Use, result.For(21).Class); + Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled); + Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 21), result.Problems); + } + + [Fact] + public void A_priced_consumer_linked_directly_below_the_grid_meter_is_billed_at_its_own_price() + { + // Review R6 (D-35, Kaskade): grid meter, and a heat pump behind it with its own price, linked grid → pump and + // no house meter. The link out of a supply meter is a supply edge (D-22), so the pump is a use root, not a + // containment child — yet the grid meter measured it. It is billed at its own price and taken out of the grid + // meter that links to it; the quantity measures do not change. + List meters = + [ + Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport), + Physical(21, "Heat pump", Electricity, MeterMode.CumulativeCounter, "kWh"), + Physical(22, "Unlinked", Electricity, MeterMode.CumulativeCounter, "kWh"), + ]; + + var result = TotalsPolicy.Classify(meters, [new(20, 21)], id => id is 21 or 22); + + Assert.Equal(MeterTotalsClass.Use, result.For(21).Class); + var billing = result.ForType(Electricity).Billing; + Assert.Equal([new SeparatelyBilledMeter(21, 20, null)], billing.SeparatelyBilled); + Assert.Contains(billing.Lines, l => l is { MeterId: 20, Kind: BillLineKind.UnitPrice } && l.Deductions.Single().MeterId == 21); + Assert.Contains(billing.Lines, l => l is { MeterId: 21, Kind: BillLineKind.OwnPrice }); + Assert.DoesNotContain(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 21), result.Problems); + + // A priced meter nothing links is still reported: nothing says the grid meter measured it. + Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 22), result.Problems); + } + + [Fact] + public void A_meter_price_on_a_meter_the_bill_never_prices_is_reported() + { + // Haus is not billed where the grid import is, so a price scoped to it applies to nothing. + var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Haus); + + Assert.Equal([new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, Haus)], result.Problems); + } + + [Fact] + public void A_subsection_below_meters_an_always_sum_replaced_comes_out_of_the_sum() + { + // 32 = 30 + 31 is billed in place of 30 and 31, and 33 below 30 has its own price: its quantity must come out + // of 32, or the bill charges it twice. + List meters = + [ + Physical(30, "House water", Water, MeterMode.CumulativeCounter, "m³"), + Physical(31, "Barn water", Water, MeterMode.CumulativeCounter, "m³"), + Virtual(32, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always), + Physical(33, "Garden", Water, MeterMode.CumulativeCounter, "m³"), + ]; + + var result = TotalsPolicy.Classify(meters, [new(30, 33)], id => id == 33); + + var billing = result.ForType(Water).Billing; + Assert.Equal([32], billing.BilledMeterIds); + Assert.Equal([new SeparatelyBilledMeter(33, 32, 30)], billing.SeparatelyBilled); + Assert.Equal([new BillDeduction(33, 1)], result.LineOf(32)!.Deductions); + } + + [Fact] + public void A_subsection_in_a_convertible_unit_is_deducted_with_its_conversion_factor() + { + var meters = Meters(); + meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "L")); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20)], id => id == 20); + + Assert.Equal([new SeparatelyBilledMeter(20, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled); + Assert.Equal([new BillDeduction(20, 0.001)], result.LineOf(Wasser)!.Deductions); + } + + [Fact] + public void A_subsection_whose_unit_cannot_convert_stays_in_its_parent_s_bill_and_is_reported() + { + // An instant-rate sensor integrating to kWh cannot come out of an m³ line. It stays inside the water bill at + // the water price; the pool below it, in m³, then comes out of the next billed meter up. + var meters = Meters(); + meters.Add(Physical(20, "Heat meter", Water, MeterMode.InstantRate, "kWh")); + meters.Add(Physical(21, "Pool", Water, MeterMode.CumulativeCounter, "m³")); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20), new(20, 21)], id => id is 20 or 21); + + Assert.Equal([new SeparatelyBilledMeter(21, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled); + Assert.Equal([new BillDeduction(21, 1)], result.LineOf(Wasser)!.Deductions); + Assert.Null(result.LineOf(20)); + Assert.Equal([new TotalsProblem(TotalsProblemKind.SeparateBillingUnitMismatch, 20, Wasser)], result.Problems); + } + + [Fact] + public void A_subsection_spanning_a_grid_meter_replacement_comes_out_of_each_grid_meter_in_service_with_it() + { + var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 1, 31) }); + meters.Add(Physical(11, "Zähler Netz (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 31))); + meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(11, Haus), new(Haus, HeatPump)], id => id == HeatPump); + + Assert.Equal( + [new SeparatelyBilledMeter(HeatPump, Netz, Haus), new SeparatelyBilledMeter(HeatPump, 11, Haus)], + result.ForType(Electricity).Billing.SeparatelyBilled); + Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(Netz)!.Deductions); + Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(11)!.Deductions); + Assert.Single(result.ForType(Electricity).Billing.Lines, l => l.MeterId == HeatPump); + } + + [Fact] + public void The_price_predicate_is_asked_once_per_meter_while_classifying_and_never_afterwards() + { + // A reader's predicate closes over a scoped DbContext; category covers computed later must not call into it. + var meters = Meters(); + meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh")); + var calls = 0; + var disposed = false; + bool HasPrice(int id) + { + ObjectDisposedException.ThrowIf(disposed, typeof(SeparatelyBilledSubmeterTests)); + calls++; + return id == HeatPump; + } + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], HasPrice); + disposed = true; + + var cover = CategoryCover.Compute(result, 101, [Haus, Netz, HeatPump]); + + Assert.Equal(meters.Count, calls); + Assert.Equal([new SeparatelyBilledMeter(HeatPump, Netz, Haus)], cover.SeparatelyBilled); + } +} diff --git a/tests/Core.Tests/Analysis/TariffUnitTests.cs b/tests/Core.Tests/Analysis/TariffUnitTests.cs new file mode 100644 index 0000000..e02a805 --- /dev/null +++ b/tests/Core.Tests/Analysis/TariffUnitTests.cs @@ -0,0 +1,744 @@ +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Tests.Analysis; + +public sealed class TariffUnitTests +{ + // ---- Parsing ---------------------------------------------------------------------------------- + + [Theory] + [InlineData("EUR/kWh", "EUR", 1, "kWh", 1)] + [InlineData("€/kWh", "EUR", 1, "kWh", 1)] + [InlineData("eur/kwh", "EUR", 1, "kWh", 1)] + [InlineData("Euro/kWh", "EUR", 1, "kWh", 1)] + [InlineData("ct/kWh", "ct", 0.01, "kWh", 1)] + [InlineData("Cent/kWh", "ct", 0.01, "kWh", 1)] + [InlineData("EUR/MWh", "EUR", 1, "MWh", 1)] + [InlineData("EUR/m3", "EUR", 1, "m³", 1)] + [InlineData("EUR/m³", "EUR", 1, "m³", 1)] + [InlineData("EUR/cbm", "EUR", 1, "m³", 1)] + [InlineData("EUR/100L", "EUR", 1, "L", 100)] + [InlineData("EUR / 100 l", "EUR", 1, "L", 100)] + [InlineData("EUR/1.000 L", "EUR", 1, "L", 1000)] + [InlineData("EUR/1000L", "EUR", 1, "L", 1000)] + [InlineData("EUR/hl", "EUR", 1, "hL", 1)] + [InlineData("EUR/h", "EUR", 1, "h", 1)] + [InlineData("EUR/Std", "EUR", 1, "h", 1)] + [InlineData("EUR/t", "EUR", 1, "t", 1)] + [InlineData("EUR per kWh", "EUR", 1, "kWh", 1)] + [InlineData("EUR je 100 L", "EUR", 1, "L", 100)] + [InlineData("SEK/kWh", "SEK", 1, "kWh", 1)] + [InlineData("Fr./kWh", "CHF", 1, "kWh", 1)] + [InlineData("Rp./kWh", "Rp", 0.01, "kWh", 1)] + [InlineData("p/kWh", "p", 0.01, "kWh", 1)] + public void A_quantity_price_parses_to_currency_scale_and_denominator( + string unit, string currency, double currencyScale, string denominator, double amount) + { + var parsed = TariffUnit.Parse(unit); + + Assert.Equal(TariffUnitBasis.Quantity, parsed.Basis); + Assert.True(parsed.IsRecognised); + Assert.Equal(currency, parsed.Currency); + Assert.Equal(currencyScale, parsed.CurrencyScale); + Assert.Equal(denominator, parsed.Denominator); + Assert.Equal(amount, parsed.DenominatorAmount); + Assert.Null(parsed.Period); + } + + [Theory] + [InlineData("EUR/month", BillingPeriod.Month)] + [InlineData("EUR/Monat", BillingPeriod.Month)] + [InlineData("€/Mon.", BillingPeriod.Month)] + [InlineData("€ pro Monat", BillingPeriod.Month)] + [InlineData("EUR monatlich", BillingPeriod.Month)] + [InlineData("EUR/1 Monat", BillingPeriod.Month)] + [InlineData("EUR/Jahr", BillingPeriod.Year)] + [InlineData("EUR/year", BillingPeriod.Year)] + [InlineData("EUR/a", BillingPeriod.Year)] + [InlineData("EUR p.a.", BillingPeriod.Year)] + [InlineData("EUR jährlich", BillingPeriod.Year)] + [InlineData("EUR/Tag", BillingPeriod.Day)] + [InlineData("EUR/day", BillingPeriod.Day)] + [InlineData("EUR/d", BillingPeriod.Day)] + [InlineData("EUR/12 Monate", BillingPeriod.Year)] + [InlineData("EUR/1 Jahr", BillingPeriod.Year)] + [InlineData("EUR/Quartal", BillingPeriod.Quarter)] + [InlineData("EUR/quarter", BillingPeriod.Quarter)] + [InlineData("EUR pro Quartal", BillingPeriod.Quarter)] + [InlineData("EUR vierteljährlich", BillingPeriod.Quarter)] + [InlineData("EUR/3 Monate", BillingPeriod.Quarter)] + public void A_standing_charge_parses_to_its_billing_period(string unit, BillingPeriod period) + { + var parsed = TariffUnit.Parse(unit); + + Assert.Equal(TariffUnitBasis.Period, parsed.Basis); + Assert.Equal(period, parsed.Period); + Assert.Equal("EUR", parsed.Currency); + Assert.Null(parsed.Denominator); + } + + [Theory] + [InlineData("pauschal")] + [InlineData("EUR")] + [InlineData("kWh")] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + [InlineData("EUR/100")] + [InlineData("EUR/kWh/h")] + [InlineData("EUR/0 L")] + [InlineData("EUR/1,5 L")] + [InlineData("EUR/1.00 L")] + [InlineData("kWh/EUR")] + [InlineData("Taler/kWh")] + [InlineData("(brutto)")] + [InlineData("brutto EUR/kWh")] + public void Anything_else_is_unparseable(string? unit) + { + var parsed = TariffUnit.Parse(unit); + + Assert.Equal(TariffUnitBasis.Unparseable, parsed.Basis); + Assert.False(parsed.IsRecognised); + Assert.Null(parsed.Denominator); + Assert.Null(parsed.Period); + } + + [Fact] + public void An_unknown_denominator_is_kept_to_match_itself() + { + var parsed = TariffUnit.Parse("EUR/Stk"); + + Assert.Equal(TariffUnitBasis.OtherDenominator, parsed.Basis); + Assert.Equal("Stk", parsed.Denominator); + Assert.False(parsed.IsRecognised); + } + + [Theory] + [InlineData("EUR/100L", "100 L")] + [InlineData("EUR/m3", "m³")] + [InlineData("EUR/Monat", "month")] + [InlineData("EUR/a", "year")] + [InlineData("pauschal", "pauschal")] + public void The_denominator_text_says_what_the_price_is_quoted_per(string unit, string text) + { + Assert.Equal(text, TariffUnit.Parse(unit).DenominatorText); + } + + [Theory] + [InlineData("EUR/kWh", TariffComponent.UnitPrice, true)] + [InlineData("ct/kWh", TariffComponent.FeedIn, true)] + [InlineData("EUR/month", TariffComponent.UnitPrice, false)] + [InlineData("EUR/Stk", TariffComponent.UnitPrice, true)] + [InlineData("pauschal", TariffComponent.UnitPrice, false)] + [InlineData("EUR/month", TariffComponent.BasePrice, true)] + [InlineData("EUR/Jahr", TariffComponent.BasePrice, true)] + [InlineData("EUR/kWh", TariffComponent.BasePrice, false)] + [InlineData("pauschal", TariffComponent.BasePrice, false)] + [InlineData("%", TariffComponent.Tax, true)] + public void The_editor_accepts_only_a_unit_shaped_for_its_component(string unit, TariffComponent component, bool suits) + { + Assert.Equal(suits, TariffUnit.Parse(unit).Suits(component)); + } + + // ---- Unit and feed-in prices -------------------------------------------------------------------- + + [Theory] + [InlineData("EUR/kWh", "kWh", 1)] + [InlineData("€/kWh", "kWh", 1)] + [InlineData("ct/kWh", "kWh", 0.01)] + [InlineData("EUR/100L", "L", 0.01)] + [InlineData("EUR/MWh", "kWh", 0.001)] + [InlineData("EUR/m3", "m³", 1)] + [InlineData("EUR/m3", "m3", 1)] + [InlineData("EUR/m³", "L", 0.001)] + [InlineData("EUR/100L", "m³", 10)] + [InlineData("EUR/kWh", "Wh", 0.001)] + [InlineData("EUR/kWh", "MWh", 1000)] + [InlineData("ct/100 L", "L", 0.0001)] + [InlineData("EUR/h", "h", 1)] + [InlineData("EUR/t", "kg", 0.001)] + public void A_matching_unit_price_applies_with_the_factor_to_currency_per_meter_unit( + string tariffUnit, string meterUnit, double factor) + { + var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Applies, fit.Fit); + Assert.True(fit.Applies); + Assert.False(fit.NeedsWarning); + Assert.Equal(TariffUnitIssue.None, fit.Issue); + Assert.Equal(factor, fit.Factor, 12); + } + + [Fact] + public void The_seeded_water_tariff_prices_the_seeded_water_meter_at_face_value() + { + // Seed: water 5.00 "EUR/m3" on a meter whose unit is "m3". Dec 2022 is 14 m³ = 70 € (D-56). + var fit = TariffUnit.Applicability("EUR/m3", "m3", TariffComponent.UnitPrice); + + Assert.Equal(70d, 14 * fit.Convert(5.00), 9); + } + + [Fact] + public void A_heating_oil_price_per_100_litres_is_charged_per_litre() + { + // 98.50 €/100 L on the seeded Öltank (L): 1,000 L cost 985 €, not 98,500 €. + var fit = TariffUnit.Applicability("EUR/100L", "L", TariffComponent.UnitPrice); + + Assert.Equal(985d, 1000 * fit.Convert(98.50), 9); + } + + [Fact] + public void A_feed_in_price_in_cents_is_converted_like_a_unit_price() + { + var fit = TariffUnit.Applicability("ct/kWh", "kWh", TariffComponent.FeedIn); + + Assert.Equal(TariffUnitFit.Applies, fit.Fit); + Assert.Equal(0.082, fit.Convert(8.2), 12); + } + + [Fact] + public void An_energy_price_does_not_price_water() + { + var fit = TariffUnit.Applicability("EUR/kWh", "m³", TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Mismatch, fit.Fit); + Assert.False(fit.Applies); + Assert.Equal(TariffUnitIssue.IncompatibleUnit, fit.Issue); + Assert.Equal("kWh", fit.TariffDenominator); + Assert.Equal("m³", fit.MeterUnit); + Assert.Equal("EUR/kWh", fit.TariffUnit); + Assert.Equal(0d, fit.Convert(0.30)); + } + + [Theory] + // A global electricity price used to price water and burner hours too (costing review, §10.2). + [InlineData("EUR/kWh", "h")] + [InlineData("EUR/100L", "h")] + [InlineData("EUR/h", "L")] + [InlineData("EUR/kWh", "kW")] + [InlineData("EUR/L", "L/min")] + [InlineData("EUR/m³", "m³/h")] + public void A_price_in_another_dimension_is_a_mismatch(string tariffUnit, string meterUnit) + { + var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Mismatch, fit.Fit); + Assert.Equal(TariffUnitIssue.IncompatibleUnit, fit.Issue); + } + + [Theory] + [InlineData("EUR/kWh", "Stk", 1)] + [InlineData("EUR/kWh", "kWh (el)", 1)] + [InlineData("EUR/m³", "Nm³", 1)] + [InlineData("ct/kWh", "Einheiten", 0.01)] + [InlineData("EUR/kWh", "mwh", 1)] + public void A_meter_unit_the_module_does_not_recognise_is_unverified_not_a_mismatch( + string tariffUnit, string meterUnit, double factor) + { + var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Unverified, fit.Fit); + Assert.True(fit.Applies); + Assert.True(fit.NeedsWarning); + Assert.Equal(TariffUnitIssue.MeterUnitUnrecognised, fit.Issue); + Assert.Equal(factor, fit.Factor, 12); + } + + [Theory] + [InlineData("EUR/kWh", "Kilowattstunden", 1)] + [InlineData("EUR/kWh", "kilowatt-hours", 1)] + [InlineData("EUR/MWh", "Kilowattstunde", 0.001)] + [InlineData("ct/kWh", "Wattstunden", 0.00001)] + [InlineData("EUR/Megawattstunde", "kWh", 0.001)] + public void Long_form_and_German_energy_units_are_priced_like_their_symbols(string tariffUnit, string meterUnit, double factor) + { + var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Applies, fit.Fit); + Assert.Equal(factor, fit.Factor, 12); + } + + [Theory] + [InlineData("mWh", 0.000_001)] + [InlineData("MWh", 1000)] + [InlineData("MWH", 1000)] + public void A_milli_unit_is_never_priced_as_a_mega_unit(string meterUnit, double factor) + { + var fit = TariffUnit.Applicability("EUR/kWh", meterUnit, TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Applies, fit.Fit); + Assert.Equal(factor, fit.Factor, 12); + } + + [Fact] + public void A_standing_charge_unit_on_a_unit_price_is_a_mismatch() + { + var fit = TariffUnit.Applicability("EUR/month", "kWh", TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Mismatch, fit.Fit); + Assert.Equal(TariffUnitIssue.PeriodForQuantity, fit.Issue); + Assert.Equal("month", fit.TariffDenominator); + } + + [Fact] + public void An_unparseable_unit_price_applies_at_face_value_with_a_warning() + { + var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Unverified, fit.Fit); + Assert.True(fit.Applies); + Assert.True(fit.NeedsWarning); + Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue); + Assert.Equal(1d, fit.Factor); + Assert.Equal("pauschal", fit.TariffDenominator); + } + + [Fact] + public void An_unparseable_unit_in_cents_still_converts_the_currency() + { + var fit = TariffUnit.Applicability("ct", "kWh", TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Unverified, fit.Fit); + Assert.Equal(0.01, fit.Factor); + } + + [Fact] + public void An_unknown_denominator_applies_to_a_meter_in_that_unit() + { + var fit = TariffUnit.Applicability("EUR/Stk", "stk", TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Applies, fit.Fit); + Assert.Equal(1d, fit.Factor); + } + + [Fact] + public void An_unknown_denominator_on_another_meter_applies_with_a_warning() + { + var fit = TariffUnit.Applicability("EUR/Stk", "kWh", TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Unverified, fit.Fit); + Assert.Equal(TariffUnitIssue.UnknownDenominator, fit.Issue); + Assert.Equal(1d, fit.Factor); + } + + // ---- Qualifiers --------------------------------------------------------------------------------- + + [Theory] + [InlineData("€/kWh brutto", TariffUnitBasis.Quantity, "kWh", "brutto")] + [InlineData("EUR/kWh netto", TariffUnitBasis.Quantity, "kWh", "netto")] + [InlineData("EUR/kWh zzgl. 19 % MwSt.", TariffUnitBasis.Quantity, "kWh", "zzgl. 19 % MwSt.")] + [InlineData("ct/kWh, inkl. USt", TariffUnitBasis.Quantity, "kWh", "inkl. USt")] + [InlineData("EUR/kWh (Grundversorgung)", TariffUnitBasis.Quantity, "kWh", "Grundversorgung")] + [InlineData("EUR/100 L [Heizöl EL] incl. VAT", TariffUnitBasis.Quantity, "100 L", "incl. VAT Heizöl EL")] + [InlineData("€/Jahr inkl. MwSt.", TariffUnitBasis.Period, "year", "inkl. MwSt.")] + [InlineData("EUR/Monat (netto)", TariffUnitBasis.Period, "month", "netto")] + [InlineData("EUR per kWh gross", TariffUnitBasis.Quantity, "kWh", "gross")] + public void VAT_notes_and_bracketed_text_are_set_aside_before_the_unit_is_read( + string unit, TariffUnitBasis basis, string denominator, string qualifier) + { + var parsed = TariffUnit.Parse(unit); + + Assert.Equal(basis, parsed.Basis); + Assert.Equal(denominator, parsed.DenominatorText); + Assert.Equal(qualifier, parsed.Qualifier); + Assert.Equal(unit, parsed.Raw); + } + + [Fact] + public void A_unit_without_a_note_has_no_qualifier() + { + Assert.Null(TariffUnit.Parse("EUR/kWh").Qualifier); + Assert.Null(TariffUnit.Parse("pauschal").Qualifier); + } + + [Fact] + public void A_price_with_a_VAT_note_applies_cleanly() + { + var fit = TariffUnit.Applicability("€/kWh brutto", "kWh", TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Applies, fit.Fit); + Assert.Equal(1d, fit.Factor); + Assert.Equal("€/kWh brutto", fit.TariffUnit); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void A_meter_without_a_unit_cannot_be_checked(string? meterUnit) + { + var fit = TariffUnit.Applicability("EUR/100L", meterUnit, TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Unverified, fit.Fit); + Assert.Equal(TariffUnitIssue.MeterUnitUnknown, fit.Issue); + Assert.Equal(0.01, fit.Factor); + } + + [Theory] + [InlineData(TariffComponent.Bonus)] + [InlineData(TariffComponent.Discount)] + [InlineData(TariffComponent.Tax)] + public void Bonus_discount_and_tax_are_not_applied(TariffComponent component) + { + var fit = TariffUnit.Applicability("EUR/kWh", "kWh", component); + + Assert.Equal(TariffUnitFit.NotApplied, fit.Fit); + Assert.False(fit.Applies); + Assert.Equal(TariffUnitIssue.ComponentNotApplied, fit.Issue); + Assert.Equal(0d, fit.Convert(12)); + } + + // ---- Standing charges --------------------------------------------------------------------------- + + [Theory] + [InlineData("EUR/month", BillingPeriod.Month)] + [InlineData("EUR/Jahr", BillingPeriod.Year)] + [InlineData("EUR/Tag", BillingPeriod.Day)] + public void A_standing_charge_applies_per_its_period_whatever_the_meter_unit(string unit, BillingPeriod period) + { + var fit = TariffUnit.Applicability(unit, "m³", TariffComponent.BasePrice); + + Assert.Equal(TariffUnitFit.Applies, fit.Fit); + Assert.Equal(period, fit.Period); + Assert.Equal(1d, fit.Factor); + } + + [Fact] + public void A_monthly_charge_accrues_over_the_days_of_each_local_month() + { + var accrual = TariffUnit.BaseAccrual("EUR/month"); + + Assert.Equal(BillingPeriod.Month, accrual.Period); + Assert.False(accrual.Assumed); + Assert.Equal(12d / 28, accrual.PerDay(12, new DateOnly(2026, 2, 10)), 12); + Assert.Equal(12d / 29, accrual.PerDay(12, new DateOnly(2024, 2, 10)), 12); + Assert.Equal(12d / 31, accrual.PerDay(12, new DateOnly(2026, 1, 31)), 12); + Assert.Equal(12d, SumOverDays(accrual, 12, new DateOnly(2026, 2, 1), new DateOnly(2026, 3, 1)), 9); + Assert.Equal(12d, SumOverDays(accrual, 12, new DateOnly(2024, 2, 1), new DateOnly(2024, 3, 1)), 9); + } + + [Fact] + public void A_yearly_charge_accrues_over_the_days_of_the_local_year() + { + var accrual = TariffUnit.BaseAccrual("EUR/Jahr"); + + Assert.Equal(BillingPeriod.Year, accrual.Period); + Assert.Equal(120d / 366, accrual.PerDay(120, new DateOnly(2024, 7, 1)), 12); + Assert.Equal(120d / 365, accrual.PerDay(120, new DateOnly(2026, 7, 1)), 12); + Assert.Equal(120d, SumOverDays(accrual, 120, new DateOnly(2024, 1, 1), new DateOnly(2025, 1, 1)), 9); + Assert.Equal(120d, SumOverDays(accrual, 120, new DateOnly(2026, 1, 1), new DateOnly(2027, 1, 1)), 9); + } + + [Fact] + public void A_daily_charge_is_charged_as_is_and_a_charge_in_cents_is_converted() + { + Assert.Equal(0.5, TariffUnit.BaseAccrual("EUR/Tag").PerDay(0.5, new DateOnly(2026, 2, 1)), 12); + Assert.Equal(0.5, TariffUnit.BaseAccrual("ct/Tag").PerDay(50, new DateOnly(2026, 2, 1)), 12); + } + + [Fact] + public void The_accrual_takes_the_month_length_directly() + { + var accrual = TariffUnit.BaseAccrual("EUR/Monat"); + + Assert.Equal(1d, accrual.PerDay(30, daysInMonth: 30, daysInYear: 365), 12); + Assert.Throws(() => accrual.PerDay(30, daysInMonth: 0, daysInYear: 365)); + } + + [Fact] + public void An_unparseable_standing_charge_is_taken_per_month_with_a_warning() + { + var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.BasePrice); + var accrual = TariffUnit.BaseAccrual("pauschal"); + + Assert.Equal(TariffUnitFit.Unverified, fit.Fit); + Assert.True(fit.NeedsWarning); + Assert.Equal(BillingPeriod.Month, fit.Period); + Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue); + Assert.Equal(BillingPeriod.Month, accrual.Period); + Assert.True(accrual.Assumed); + Assert.Equal(10d / 30, accrual.PerDay(10, new DateOnly(2026, 4, 15)), 12); + } + + [Theory] + [InlineData("€/Jahr inkl. MwSt.", 120d / 365)] + [InlineData("EUR/Jahr brutto", 120d / 365)] + [InlineData("EUR/12 Monate", 120d / 365)] + [InlineData("EUR/Quartal", 120d / 92)] + public void A_standing_charge_is_never_taken_per_month_when_its_period_can_be_read(string unit, double perDay) + { + var accrual = TariffUnit.BaseAccrual(unit); + + Assert.False(accrual.Assumed); + Assert.Equal(TariffUnitFit.Applies, accrual.Fit); + Assert.Equal(perDay, accrual.PerDay(120, new DateOnly(2026, 7, 1)), 12); + } + + [Fact] + public void A_quarterly_charge_accrues_over_the_days_of_its_local_calendar_quarter() + { + var accrual = TariffUnit.BaseAccrual("EUR/Quartal"); + + Assert.Equal(BillingPeriod.Quarter, accrual.Period); + Assert.Equal(90, accrual.DaysInPeriod(new DateOnly(2026, 2, 14))); + Assert.Equal(91, accrual.DaysInPeriod(new DateOnly(2024, 3, 31))); + Assert.Equal(91, accrual.DaysInPeriod(new DateOnly(2026, 4, 1))); + Assert.Equal(92, accrual.DaysInPeriod(new DateOnly(2026, 8, 31))); + Assert.Equal(92, accrual.DaysInPeriod(new DateOnly(2026, 12, 31))); + Assert.Equal(30d, SumOverDays(accrual, 30, new DateOnly(2024, 1, 1), new DateOnly(2024, 4, 1)), 9); + Assert.Equal(30d, SumOverDays(accrual, 30, new DateOnly(2026, 7, 1), new DateOnly(2026, 10, 1)), 9); + Assert.Equal(120d, SumOverDays(accrual, 30, new DateOnly(2026, 1, 1), new DateOnly(2027, 1, 1)), 9); + } + + [Fact] + public void A_quarterly_charge_cannot_be_spread_by_month_and_year_lengths_alone() + { + var accrual = TariffUnit.BaseAccrual("EUR/Quartal"); + + Assert.Throws(() => accrual.PerDay(30, daysInMonth: 31, daysInYear: 365)); + } + + [Theory] + [InlineData("EUR/2 Monate", "2 Monate")] + [InlineData("EUR/Woche", "Woche")] + [InlineData("EUR/Halbjahr", "Halbjahr")] + [InlineData("EUR/7 Tage", "7 Tage")] + [InlineData("EUR halbjährlich", "halbjährlich")] + public void A_period_that_is_read_but_cannot_be_accrued_is_a_mismatch_not_a_month(string unit, string written) + { + var parsed = TariffUnit.Parse(unit); + var fit = TariffUnit.Applicability(unit, "kWh", TariffComponent.BasePrice); + var accrual = TariffUnit.BaseAccrual(unit); + + Assert.Equal(TariffUnitBasis.UnsupportedPeriod, parsed.Basis); + Assert.True(parsed.IsRecognised); + Assert.Equal(written, parsed.DenominatorText); + Assert.False(parsed.Suits(TariffComponent.BasePrice)); + Assert.Equal(TariffUnitFit.Mismatch, fit.Fit); + Assert.Equal(TariffUnitIssue.UnsupportedPeriod, fit.Issue); + Assert.Equal(0d, fit.Factor); + Assert.Equal(TariffUnitFit.Mismatch, accrual.Fit); + Assert.False(accrual.Applies); + Assert.False(accrual.Assumed); + Assert.Equal(0d, accrual.PerDay(24, new DateOnly(2026, 5, 1))); + } + + [Fact] + public void A_period_on_a_unit_price_is_a_mismatch_even_when_it_cannot_be_accrued() + { + var fit = TariffUnit.Applicability("EUR/2 Monate", "kWh", TariffComponent.UnitPrice); + + Assert.Equal(TariffUnitFit.Mismatch, fit.Fit); + Assert.Equal(TariffUnitIssue.PeriodForQuantity, fit.Issue); + } + + [Fact] + public void A_standing_charge_quoted_per_quantity_is_taken_per_month_with_a_warning() + { + var accrual = TariffUnit.BaseAccrual("EUR/kWh"); + + Assert.True(accrual.Assumed); + Assert.Equal(BillingPeriod.Month, accrual.Period); + Assert.Equal(TariffUnitIssue.QuantityForPeriod, accrual.Issue); + } + + // ---- Currency ----------------------------------------------------------------------------------- + + [Theory] + [InlineData("USD/kWh", "EUR")] + [InlineData("SEK/kWh", "EUR")] + [InlineData("£/kWh", "EUR")] + [InlineData("p/kWh", "EUR")] + [InlineData("Rp./kWh", "EUR")] + [InlineData("ct/kWh", "GBP")] + [InlineData("EUR/kWh", "CHF")] + [InlineData("USD", "EUR")] + public void A_price_in_another_currency_does_not_price_the_instance_currency(string tariffUnit, string currency) + { + var fit = TariffUnit.Applicability(tariffUnit, "kWh", TariffComponent.UnitPrice, currency); + + Assert.Equal(TariffUnitFit.Mismatch, fit.Fit); + Assert.Equal(TariffUnitIssue.CurrencyMismatch, fit.Issue); + Assert.Equal(0d, fit.Convert(0.30)); + } + + [Theory] + [InlineData("EUR/kWh", "EUR", 1)] + [InlineData("€/kWh", "eur", 1)] + [InlineData("EUR/kWh", "€", 1)] + [InlineData("ct/kWh", "EUR", 0.01)] + [InlineData("Cent/kWh", "USD", 0.01)] + [InlineData("p/kWh", "GBP", 0.01)] + [InlineData("Rp./kWh", "CHF", 0.01)] + [InlineData("Fr./kWh", "CHF", 1)] + [InlineData("SEK/kWh", "sek", 1)] + public void A_minor_unit_applies_only_under_its_own_major_currency(string tariffUnit, string currency, double factor) + { + var fit = TariffUnit.Applicability(tariffUnit, "kWh", TariffComponent.UnitPrice, currency); + + Assert.Equal(TariffUnitFit.Applies, fit.Fit); + Assert.Equal(factor, fit.Factor, 12); + } + + [Fact] + public void Without_an_expected_currency_the_currency_is_not_checked() + { + Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability("USD/kWh", "kWh", TariffComponent.UnitPrice).Fit); + } + + [Fact] + public void A_unit_without_a_currency_cannot_be_checked_against_one() + { + var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.UnitPrice, "EUR"); + + Assert.Equal(TariffUnitFit.Unverified, fit.Fit); + Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue); + } + + [Fact] + public void A_standing_charge_in_another_currency_accrues_nothing() + { + var fit = TariffUnit.Applicability("USD/month", "kWh", TariffComponent.BasePrice, "EUR"); + var accrual = TariffUnit.BaseAccrual("USD/month", "EUR"); + + Assert.Equal(TariffUnitFit.Mismatch, fit.Fit); + Assert.Equal(TariffUnitIssue.CurrencyMismatch, fit.Issue); + Assert.Equal(BillingPeriod.Month, fit.Period); + Assert.Equal(TariffUnitFit.Mismatch, accrual.Fit); + Assert.Equal(0d, accrual.PerDay(12, new DateOnly(2026, 2, 1))); + Assert.Equal(TariffUnitFit.Applies, TariffUnit.BaseAccrual("EUR/month", "EUR").Fit); + } + + [Fact] + public void Bonus_discount_and_tax_stay_not_applied_whatever_their_currency() + { + Assert.Equal(TariffUnitFit.NotApplied, TariffUnit.Applicability("USD/kWh", "kWh", TariffComponent.Tax, "EUR").Fit); + } + + [Fact] + public void The_expected_currency_must_be_named() + { + Assert.Throws(() => TariffUnit.Applicability("EUR/kWh", "kWh", TariffComponent.UnitPrice, " ")); + Assert.Throws(() => TariffUnit.BaseAccrual("EUR/month", "")); + } + + // ---- Parsed once -------------------------------------------------------------------------------- + + [Theory] + [InlineData("EUR/kWh", "MWh", TariffComponent.UnitPrice)] + [InlineData("EUR/100L", "L", TariffComponent.UnitPrice)] + [InlineData("pauschal", "kWh", TariffComponent.UnitPrice)] + [InlineData("EUR/Jahr", "m³", TariffComponent.BasePrice)] + [InlineData("USD/kWh", "kWh", TariffComponent.FeedIn)] + [InlineData("EUR/kWh", "kWh", TariffComponent.Tax)] + public void A_unit_parsed_once_is_checked_exactly_like_its_text(string tariffUnit, string meterUnit, TariffComponent component) + { + var parsed = TariffUnit.Parse(tariffUnit); + + Assert.Equal( + TariffUnit.Applicability(tariffUnit, meterUnit, component), + TariffUnit.Applicability(parsed, meterUnit, component)); + Assert.Equal( + TariffUnit.Applicability(tariffUnit, meterUnit, component, "EUR"), + TariffUnit.Applicability(parsed, meterUnit, component, "EUR")); + Assert.Equal(TariffUnit.BaseAccrual(tariffUnit), TariffUnit.BaseAccrual(parsed)); + Assert.Equal(TariffUnit.BaseAccrual(tariffUnit, "EUR"), TariffUnit.BaseAccrual(parsed, "EUR")); + } + + // ---- Alias tables ------------------------------------------------------------------------------- + + public static TheoryData CurrencyAliases + { + get + { + var data = new TheoryData(); + foreach (var entry in TariffUnit.CurrencyAliasTable) + { + data.Add(entry.Alias, entry.Code, entry.Scale, entry.Majors[0]); + } + + return data; + } + } + + public static TheoryData PeriodAliases + { + get + { + var data = new TheoryData(); + foreach (var entry in TariffUnit.PeriodAliasTable) + { + data.Add(entry.Alias); + } + + return data; + } + } + + [Theory] + [MemberData(nameof(CurrencyAliases))] + public void Every_currency_spelling_parses_to_its_code_and_scale_and_prices_its_major_currency( + string alias, string code, double scale, string major) + { + var parsed = TariffUnit.Parse($"{alias}/kWh"); + + Assert.Equal(TariffUnitBasis.Quantity, parsed.Basis); + Assert.Equal(code, parsed.Currency); + Assert.Equal(scale, parsed.CurrencyScale); + Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability(parsed, "kWh", TariffComponent.UnitPrice, major).Fit); + } + + [Theory] + [MemberData(nameof(PeriodAliases))] + public void Every_period_spelling_is_read_as_a_period(string alias) + { + var slashed = TariffUnit.Parse($"EUR/{alias}"); + var spaced = TariffUnit.Parse($"EUR pro {alias}"); + + Assert.Contains(slashed.Basis, new[] { TariffUnitBasis.Period, TariffUnitBasis.UnsupportedPeriod }); + Assert.Equal(slashed.Basis, spaced.Basis); + Assert.Equal(slashed.Period, spaced.Period); + if (slashed.Basis == TariffUnitBasis.Period) + { + // A supported period round-trips through the token it is written with. + Assert.Equal(slashed.Period, TariffUnit.Parse($"EUR/{TariffUnit.PeriodToken(slashed.Period!.Value)}").Period); + } + } + + // ---- Suggestions -------------------------------------------------------------------------------- + + [Theory] + [InlineData(TariffComponent.UnitPrice, "m3", "EUR/m³")] + [InlineData(TariffComponent.FeedIn, "kWh", "EUR/kWh")] + [InlineData(TariffComponent.BasePrice, "kWh", "EUR/month")] + [InlineData(TariffComponent.UnitPrice, "", "EUR")] + [InlineData(TariffComponent.Tax, "kWh", "EUR")] + public void The_editor_suggests_a_unit_for_the_component(TariffComponent component, string meterUnit, string expected) + { + Assert.Equal(expected, TariffUnit.Suggest(component, meterUnit)); + } + + [Theory] + [InlineData(TariffComponent.UnitPrice, "m3")] + [InlineData(TariffComponent.FeedIn, "kWh")] + [InlineData(TariffComponent.UnitPrice, "L")] + [InlineData(TariffComponent.BasePrice, "kWh")] + public void A_suggested_unit_applies_cleanly_to_the_meter_it_was_suggested_for(TariffComponent component, string meterUnit) + { + var suggested = TariffUnit.Suggest(component, meterUnit); + + Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability(suggested, meterUnit, component).Fit); + Assert.True(TariffUnit.Parse(suggested).Suits(component)); + } + + private static double SumOverDays(BasePriceAccrual accrual, double value, DateOnly from, DateOnly to) + { + var total = 0d; + for (var day = from; day < to; day = day.AddDays(1)) + { + total += accrual.PerDay(value, day); + } + + return total; + } +} diff --git a/tests/Core.Tests/Analysis/TotalsPolicyTests.cs b/tests/Core.Tests/Analysis/TotalsPolicyTests.cs new file mode 100644 index 0000000..2834717 --- /dev/null +++ b/tests/Core.Tests/Analysis/TotalsPolicyTests.cs @@ -0,0 +1,940 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; +using static MeterVault.Core.Tests.Analysis.TotalsSeed; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// The per-type totals policy (D-22, D-23, D-34, D-35, D-53). The seeded topology is the golden case: summing +/// every meter there counts Haus, Netz and Auto on top of each other, and the sheet bills Netz alone — so these +/// tests pin that the policy finds exactly one non-overlapping meter per measure, bills the grid import, and +/// refuses any override that would count the same energy twice. +/// +public sealed class TotalsPolicyTests +{ + [Fact] + public void The_seeded_topology_classifies_every_meter_as_the_note_pins() + { + var result = TotalsPolicy.Classify(Meters(), Links()); + + Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class); + Assert.Equal(MeterTotalsReason.TotalLoadRole, result.For(Haus).Reason); + Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class); + Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class); + Assert.Equal(Haus, result.For(Auto).ParentId); + Assert.Equal(MeterTotalsReason.ContainedByLink, result.For(Auto).Reason); + Assert.Equal(MeterTotalsClass.Generation, result.For(Solar1).Class); + Assert.Equal(MeterTotalsClass.Generation, result.For(Solar2).Class); + Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(SummeSolar).Class); + Assert.Equal(MeterTotalsReason.VirtualView, result.For(SummeSolar).Reason); + Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class); + Assert.Equal(MeterTotalsReason.ConsumptionRoot, result.For(Wasser).Reason); + Assert.Equal(MeterTotalsClass.Use, result.For(Oeltank).Class); + Assert.Equal(MeterTotalsClass.Runtime, result.For(Brenner).Class); + } + + [Fact] + public void Seeded_measures_are_one_set_per_type_and_unit() + { + var result = TotalsPolicy.Classify(Meters(), Links()); + + Assert.Equal( + [ + new MeasureGroup(TotalsMeasure.Use, "kWh", [Haus]), + new MeasureGroup(TotalsMeasure.GridImport, "kWh", [Netz]), + new MeasureGroup(TotalsMeasure.Generation, "kWh", [Solar1, Solar2]), + ], + result.ForType(Electricity).Measures, + MeasureGroupComparer.Instance); + Assert.Equal([new MeasureGroup(TotalsMeasure.Use, "m³", [Wasser])], result.ForType(Water).Measures, MeasureGroupComparer.Instance); + Assert.Equal( + [ + new MeasureGroup(TotalsMeasure.Use, "L", [Oeltank]), + new MeasureGroup(TotalsMeasure.Runtime, "h", [Brenner]), + ], + result.ForType(Oil).Measures, + MeasureGroupComparer.Instance); + Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Export)); + } + + [Fact] + public void The_seeded_bill_is_the_grid_import_for_electricity_and_household_use_elsewhere() + { + var result = TotalsPolicy.Classify(Meters(), Links()); + + var electricity = result.ForType(Electricity).Billing; + Assert.Equal(BillingBasis.GridImport, electricity.Basis); + Assert.Equal([Netz], electricity.BilledMeterIds); + Assert.Empty(electricity.FeedInMeterIds); + Assert.Empty(electricity.SeparatelyBilled); + + Assert.Equal(BillingBasis.Use, result.ForType(Water).Billing.Basis); + Assert.Equal([Wasser], result.ForType(Water).Billing.BilledMeterIds); + Assert.Equal([Oeltank], result.ForType(Oil).Billing.BilledMeterIds); + Assert.Equal([Netz, Wasser, Oeltank], result.BillItems.Order()); + Assert.False(result.IsBilled(Solar1)); + Assert.False(result.IsBilled(Brenner)); + } + + [Fact] + public void The_seeded_configuration_raises_no_problem_and_no_overlap_hint() + { + var result = TotalsPolicy.Classify(Meters(), Links()); + + Assert.Empty(result.Problems); + Assert.Empty(result.Hints); + Assert.Empty(TotalsPolicy.PossibleOverlapHints(Meters(), Links())); + } + + [Fact] + public void A_link_out_of_a_supply_meter_never_makes_its_target_a_subsection() + { + // Netz → Haus and Summe Solar → Haus say what feeds the house, not that the house is part of them. + var result = TotalsPolicy.Classify(Meters(), Links()); + + Assert.Empty(result.For(Haus).ParentIds); + Assert.Empty(result.For(SummeSolar).ParentIds); + } + + [Fact] + public void Lifecycle_dates_move_no_meter_where_no_role_passes_between_meters() + { + var plain = TotalsPolicy.Classify(Meters(), Links()); + var dated = TotalsPolicy.Classify( + [.. Meters().Select(m => m.Id switch + { + Auto => m with { RetiredAt = new DateOnly(2024, 6, 30) }, + Solar2 => m with { InstalledAt = new DateOnly(2025, 3, 1) }, + Netz => m with { InstalledAt = new DateOnly(2022, 9, 1), RetiredAt = new DateOnly(2023, 1, 31) }, + _ => m, + })], + Links()); + + foreach (var id in plain.Meters.Keys) + { + Assert.Equal(plain.For(id).Class, dated.For(id).Class); + Assert.Equal(plain.For(id).Measure, dated.For(id).Measure); + } + + Assert.Equal(plain.BillItems.Order(), dated.BillItems.Order()); + } + + [Fact] + public void The_result_does_not_depend_on_the_order_meters_and_links_arrive_in() + { + var forward = TotalsPolicy.Classify(Meters(), Links()); + var backward = TotalsPolicy.Classify(Enumerable.Reverse(Meters()), Enumerable.Reverse(Links())); + + foreach (var id in forward.Meters.Keys) + { + Assert.Equal(forward.For(id), backward.For(id), EntryComparer.Instance); + } + } + + [Fact] + public void A_bidirectional_meter_without_total_load_bills_the_import_credits_the_export_and_leaves_use_unmeasured() + { + // Import 1.8.0, export 2.8.0 and PV, no house meter: none of them is household use — the import is supply, + // the export is never consumption, and generation is not use. Use stays empty rather than guessed; the bill + // is the import and the feed-in credit is earned by the export register only (never by generation). + List meters = + [ + Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport), + Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridExport), + Physical(22, "PV", Electricity, MeterMode.GenerationCounter, "kWh"), + ]; + + var totals = TotalsPolicy.Classify(meters, [new(22, 20)]).ForType(Electricity); + + Assert.Empty(totals.MetersIn(TotalsMeasure.Use)); + Assert.Equal([20], totals.MetersIn(TotalsMeasure.GridImport)); + Assert.Equal([21], totals.MetersIn(TotalsMeasure.Export)); + Assert.Equal([22], totals.MetersIn(TotalsMeasure.Generation)); + Assert.Equal(BillingBasis.GridImport, totals.Billing.Basis); + Assert.Equal([20], totals.Billing.BilledMeterIds); + Assert.Equal([21], totals.Billing.FeedInMeterIds); + } + + [Fact] + public void A_virtual_meter_never_holds_a_role_so_a_virtual_total_load_stays_an_analysis_view() + { + // A-07: a virtual meter is a view over other meters. Counting a stored total_load on it would put a calculation + // into household use (and bill it where no grid import exists), which D-39 allows only through Always. + List meters = + [ + Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport), + Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridExport), + Physical(22, "PV", Electricity, MeterMode.GenerationCounter, "kWh"), + Virtual(23, "Household", Electricity, "kWh", QuantityKind.Net, [20, 21, 22], pureSum: false, MeterRoles.TotalLoad), + ]; + + var result = TotalsPolicy.Classify(meters, []); + + Assert.Null(meters[3].Role); + Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(23).Class); + Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.Equal([20], result.ForType(Electricity).Billing.BilledMeterIds); + Assert.Empty(result.Problems); + } + + [Fact] + public void A_virtual_total_load_in_a_type_without_a_grid_import_is_never_billed() + { + // The probed case: the role made the view household use, and with no grid import that meant billing it. + var meters = Meters(); + meters.Add(Virtual(22, "Wasser view", Water, "m³", QuantityKind.Consumption, [Wasser], pureSum: true, MeterRoles.TotalLoad)); + + var result = TotalsPolicy.Classify(meters, Links()); + + Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(22).Class); + Assert.Equal([Wasser], result.ForType(Water).Billing.BilledMeterIds); + Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class); + } + + [Fact] + public void A_role_handed_directly_to_a_virtual_meter_is_ignored_and_reported() + { + // A caller that skips MeterRoleRules.Effective still cannot make a view hold a role. + var meters = Meters(); + meters.Add(Virtual(23, "House view", Electricity, "kWh", QuantityKind.Consumption, [Haus], pureSum: true) with { Role = MeterRole.TotalLoad }); + + var result = TotalsPolicy.Classify(meters, Links()); + + Assert.Contains(new TotalsProblem(TotalsProblemKind.RoleNotApplicable, 23, Role: MeterRole.TotalLoad), result.Problems); + Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(23).Class); + Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole); + } + + [Theory] + [InlineData("GRID_IMPORT")] + [InlineData(" Grid_Import ")] + [InlineData("grid_import")] + public void A_role_token_counts_whatever_its_case_and_surrounding_spaces(string token) + { + var result = TotalsPolicy.Classify( + MetersWith(Netz, m => Physical(m.Id, m.Name, m.EnergyTypeId, m.Mode, m.Unit, token)), + Links()); + + Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class); + Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class); + Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class); + Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds); + Assert.Empty(result.Problems); + } + + [Theory] + [InlineData(MeterRoles.GridImport)] + [InlineData(MeterRoles.GridExport)] + [InlineData(MeterRoles.TotalLoad)] + public void A_role_stored_on_a_tank_plays_no_part_so_the_tank_is_billed_as_household_use(string token) + { + // A tank measures a store, not a flow (MeterRoleRules): a stored grid_import must not bill it as the grid, + // and a stored grid_export must not earn it a feed-in credit. + var result = TotalsPolicy.Classify( + MetersWith(Oeltank, m => Physical(m.Id, m.Name, m.EnergyTypeId, m.Mode, m.Unit, token)), + Links()); + + Assert.Equal(MeterTotalsClass.Use, result.For(Oeltank).Class); + Assert.Equal(BillingBasis.Use, result.ForType(Oil).Billing.Basis); + Assert.Equal([Oeltank], result.ForType(Oil).Billing.BilledMeterIds); + Assert.Empty(result.ForType(Oil).Billing.FeedInMeterIds); + } + + [Fact] + public void A_meter_reporting_export_is_never_consumption_even_without_the_role() + { + List meters = + [ + Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport), + Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", kind: QuantityKind.Export), + ]; + + var result = TotalsPolicy.Classify(meters, []); + + Assert.Equal(MeterTotalsClass.Export, result.For(21).Class); + Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.Equal([21], result.ForType(Electricity).Billing.FeedInMeterIds); + } + + [Fact] + public void Always_on_Summe_Solar_replaces_both_solar_meters_in_generation() + { + var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always)); + + var result = TotalsPolicy.Classify(meters, Links()); + + Assert.Equal([SummeSolar], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation)); + Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(SummeSolar).Class); + Assert.Equal(TotalsMeasure.Generation, result.For(SummeSolar).Measure); + Assert.Equal([Solar1, Solar2], result.For(SummeSolar).ReplacesIds); + foreach (var solar in new[] { Solar1, Solar2 }) + { + Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(solar).Class); + Assert.Equal(MeterTotalsReason.CoveredByOverride, result.For(solar).Reason); + Assert.Equal(SummeSolar, result.For(solar).RelatedMeterId); + Assert.Equal(MeterTotalsClass.Generation, result.For(solar).Natural); + } + + Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds); + Assert.True(TotalsPolicy.Validate(Meters(), Links(), SummeSolar, TotalsOverride.Always).IsAllowed); + } + + [Fact] + public void Always_on_Auto_while_Haus_is_counted_is_refused_naming_Haus() + { + var check = TotalsPolicy.Validate(Meters(), Links(), Auto, TotalsOverride.Always); + + Assert.False(check.IsAllowed); + Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict); + } + + [Fact] + public void A_stored_always_that_overlaps_is_not_applied_and_is_reported() + { + var result = TotalsPolicy.Classify(MetersWithOverride((Auto, TotalsOverride.Always)), Links()); + + Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class); + Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), result.For(Auto).RefusedOverride); + Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.Contains(new TotalsProblem(TotalsProblemKind.OverrideRefused, Auto, Haus, Conflict: TotalsConflictReason.OverlapsCountedMeter), result.Problems); + } + + [Fact] + public void Always_on_Auto_is_allowed_once_Haus_is_set_to_never() + { + var neverHaus = MetersWithOverride((Haus, TotalsOverride.Never)); + Assert.True(TotalsPolicy.Validate(neverHaus, Links(), Auto, TotalsOverride.Always).IsAllowed); + + var result = TotalsPolicy.Classify(MetersWithOverride((Haus, TotalsOverride.Never), (Auto, TotalsOverride.Always)), Links()); + + Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Haus).Class); + Assert.Equal(MeterTotalsReason.OverrideNever, result.For(Haus).Reason); + Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(Auto).Class); + Assert.Empty(result.For(Auto).ReplacesIds); + Assert.Equal([Auto], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds); + } + + [Fact] + public void Putting_Haus_back_to_auto_is_refused_while_Autos_always_depends_on_its_absence() + { + var meters = MetersWithOverride((Haus, TotalsOverride.Never), (Auto, TotalsOverride.Always)); + + var check = TotalsPolicy.Validate(meters, Links(), Haus, TotalsOverride.Auto); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.DisplacesOverride, Auto), check.Conflict); + } + + [Fact] + public void Always_on_a_solar_meter_that_Summe_Solar_replaces_is_refused_naming_Summe_Solar() + { + var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always)); + + var check = TotalsPolicy.Validate(meters, Links(), Solar1, TotalsOverride.Always); + + Assert.False(check.IsAllowed); + Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, SummeSolar), check.Conflict); + } + + [Fact] + public void Always_on_a_virtual_that_depends_on_an_always_meter_is_refused_naming_it() + { + var meters = MetersWithOverride((Solar1, TotalsOverride.Always)); + + var check = TotalsPolicy.Validate(meters, Links(), SummeSolar, TotalsOverride.Always); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Solar1), check.Conflict); + } + + [Fact] + public void Two_stored_always_sums_over_the_same_sources_keep_the_lower_id_and_report_the_other() + { + var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always)); + meters.Add(Virtual(10, "PV total (copy)", Electricity, "kWh", QuantityKind.Generation, [Solar1, Solar2], pureSum: true, totals: TotalsOverride.Always)); + + var result = TotalsPolicy.Classify(meters, Links()); + + Assert.Equal([SummeSolar], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation)); + Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(10).Class); + Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, SummeSolar), result.For(10).RefusedOverride); + Assert.Contains(result.Problems, p => p.Kind == TotalsProblemKind.OverrideRefused && p.MeterId == 10); + } + + [Fact] + public void Always_on_a_virtual_over_a_subsection_is_refused_naming_its_counted_ancestor() + { + var meters = Meters(); + meters.Add(Virtual(10, "Car only", Electricity, "kWh", QuantityKind.Consumption, [Auto], pureSum: true)); + + var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict); + } + + [Fact] + public void Always_on_a_virtual_whose_sources_nest_is_refused_because_its_sum_double_counts() + { + var meters = Meters(); + meters.Add(Virtual(10, "House plus car", Electricity, "kWh", QuantityKind.Consumption, [Haus, Auto], pureSum: true)); + + var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, Haus), check.Conflict); + } + + [Fact] + public void Always_on_a_formula_that_is_not_a_pure_sum_is_refused() + { + var meters = Meters(); + meters.Add(Virtual(10, "Netz Einsparung", Electricity, "kWh", QuantityKind.Consumption, [Haus, Netz], pureSum: false)); + + var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAPureSum, null), check.Conflict); + } + + [Theory] + [InlineData(QuantityKind.Net)] + [InlineData(QuantityKind.Indicator)] + public void Always_on_a_net_or_indicator_view_is_refused_as_not_additive(QuantityKind kind) + { + var meters = Meters(); + meters.Add(Virtual(10, "View", Electricity, "kWh", kind, [Haus, Netz], pureSum: false)); + + var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAdditive, null), check.Conflict); + } + + [Fact] + public void A_virtual_without_a_usable_definition_cannot_be_counted() + { + var meters = Meters(); + meters.Add(Virtual(10, "Needs configuration", Electricity, "kWh", QuantityKind.Consumption, [], pureSum: true)); + + var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always); + + Assert.Equal(TotalsConflictReason.NotAPureSum, check.Conflict!.Reason); + } + + [Fact] + public void Always_on_a_consumption_sum_of_roots_replaces_them_in_use_and_in_the_bill() + { + List meters = + [ + Physical(30, "House water", Water, MeterMode.CumulativeCounter, "m³"), + Physical(31, "Garden water", Water, MeterMode.CumulativeCounter, "m³"), + Virtual(32, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always), + ]; + + var result = TotalsPolicy.Classify(meters, []); + + Assert.Equal([32], result.ForType(Water).MetersIn(TotalsMeasure.Use)); + Assert.Equal([30, 31], result.For(32).ReplacesIds); + Assert.Equal(BillingBasis.Use, result.ForType(Water).Billing.Basis); + Assert.Equal([32], result.ForType(Water).Billing.BilledMeterIds); + } + + [Fact] + public void Always_on_a_meter_that_already_counts_changes_nothing() + { + var result = TotalsPolicy.Classify(MetersWithOverride((Haus, TotalsOverride.Always)), Links()); + + Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class); + Assert.Null(result.For(Haus).RefusedOverride); + Assert.True(TotalsPolicy.Validate(Meters(), Links(), Haus, TotalsOverride.Always).IsAllowed); + } + + [Fact] + public void Never_on_the_grid_import_makes_the_type_bill_household_use() + { + var result = TotalsPolicy.Classify(MetersWithOverride((Netz, TotalsOverride.Never)), Links()); + + Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Netz).Class); + Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Natural); + Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.GridImport)); + Assert.Equal(BillingBasis.Use, result.ForType(Electricity).Billing.Basis); + Assert.Equal([Haus], result.ForType(Electricity).Billing.BilledMeterIds); + Assert.True(TotalsPolicy.Validate(Meters(), Links(), Netz, TotalsOverride.Never).IsAllowed); + } + + [Fact] + public void Never_keeps_a_subsection_s_parent_so_the_page_can_still_explain_it() + { + var result = TotalsPolicy.Classify(MetersWithOverride((Auto, TotalsOverride.Never)), Links()); + + Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Auto).Class); + Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Natural); + Assert.Equal([Haus], result.For(Auto).ParentIds); + } + + [Fact] + public void A_duplicated_role_is_kept_by_the_lowest_id_and_the_other_meter_is_reported() + { + var meters = Meters(); + meters.Add(Physical(10, "Second grid meter", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport)); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]); + + Assert.Equal(MeterTotalsClass.NotCounted, result.For(10).Class); + Assert.Equal(MeterTotalsReason.DuplicateRole, result.For(10).Reason); + Assert.Equal(Netz, result.For(10).RelatedMeterId); + Assert.Contains(new TotalsProblem(TotalsProblemKind.DuplicateRole, 10, Netz, MeterRole.GridImport), result.Problems); + Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds); + + // The loser still measures the grid: its link into Haus is a supply edge, and it never joins household use. + Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class); + Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.Equal(TotalsConflictReason.RoleConflict, TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always).Conflict!.Reason); + } + + [Fact] + public void The_same_role_in_different_energy_types_is_no_conflict() + { + var meters = Meters(); + meters.Add(Physical(10, "Main water", Water, MeterMode.CumulativeCounter, "m³", MeterRoles.TotalLoad)); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Wasser)]); + + Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole); + Assert.Equal([10], result.ForType(Water).MetersIn(TotalsMeasure.Use)); + Assert.Equal(MeterTotalsClass.Breakdown, result.For(Wasser).Class); + } + + [Fact] + public void A_role_handed_to_a_mode_that_cannot_hold_it_is_ignored_and_reported() + { + var meters = MetersWith(Solar1, m => m with { Role = MeterRole.TotalLoad }); + + var result = TotalsPolicy.Classify(meters, Links()); + + Assert.Contains(new TotalsProblem(TotalsProblemKind.RoleNotApplicable, Solar1, Role: MeterRole.TotalLoad), result.Problems); + Assert.Equal(MeterTotalsClass.Generation, result.For(Solar1).Class); + Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + } + + [Fact] + public void An_unlinked_consumption_meter_is_taken_as_part_of_the_total_load_and_hinted() + { + var meters = Meters(); + meters.Add(Physical(10, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh")); + + var result = TotalsPolicy.Classify(meters, Links()); + + Assert.Equal(MeterTotalsClass.Breakdown, result.For(10).Class); + Assert.Equal(MeterTotalsReason.AssumedInsideTotalLoad, result.For(10).Reason); + Assert.Equal(Haus, result.For(10).ParentId); + Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.Contains(new OverlapHint(OverlapHintKind.NotLinkedBelowTotalLoad, Electricity, 10, Haus), result.Hints); + + // Counting it as well would add a part of Haus on top of Haus. + var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always); + Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict); + } + + [Fact] + public void A_grid_import_not_linked_to_the_total_load_raises_a_possible_overlap_hint() + { + var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).ToList(); + + var result = TotalsPolicy.Classify(Meters(), links); + + Assert.Equal([new OverlapHint(OverlapHintKind.GridImportNotLinkedToTotalLoad, Electricity, Netz, Haus)], result.Hints); + Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds); + Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + } + + [Fact] + public void A_grid_import_reaching_the_total_load_through_another_meter_is_linked() + { + var meters = Meters(); + meters.Add(Physical(10, "Sub-distribution", Electricity, MeterMode.CumulativeCounter, "kWh")); + var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).Append(new(Netz, 10)).Append(new(10, Haus)).ToList(); + + var result = TotalsPolicy.Classify(meters, links); + + Assert.DoesNotContain(result.Hints, h => h.Kind == OverlapHintKind.GridImportNotLinkedToTotalLoad); + } + + [Fact] + public void A_generation_meter_below_another_generation_meter_is_a_breakdown_not_a_second_root() + { + var meters = Meters(); + meters.Add(Physical(10, "Inverter", Electricity, MeterMode.GenerationCounter, "kWh")); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Solar1)]); + + Assert.Equal(MeterTotalsClass.Breakdown, result.For(Solar1).Class); + Assert.Equal(10, result.For(Solar1).ParentId); + Assert.Equal([Solar2, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation)); + } + + [Fact] + public void Runtime_meters_all_count_even_when_linked_below_a_tank() + { + var result = TotalsPolicy.Classify(Meters(), [.. Links(), new(Oeltank, Brenner)]); + + Assert.Equal(MeterTotalsClass.Runtime, result.For(Brenner).Class); + Assert.Equal([Oeltank], result.ForType(Oil).MetersIn(TotalsMeasure.Use)); + Assert.Equal([Brenner], result.ForType(Oil).MetersIn(TotalsMeasure.Runtime)); + } + + [Fact] + public void Measures_never_add_across_units() + { + List meters = + [ + Physical(30, "Main", Water, MeterMode.CumulativeCounter, "m³"), + Physical(31, "Cistern", Water, MeterMode.DirectDelta, "L"), + ]; + + var groups = TotalsPolicy.Classify(meters, []).ForType(Water).GroupsOf(TotalsMeasure.Use); + + Assert.Equal( + [new MeasureGroup(TotalsMeasure.Use, "L", [31]), new MeasureGroup(TotalsMeasure.Use, "m³", [30])], + groups, + MeasureGroupComparer.Instance); + } + + [Fact] + public void Links_across_energy_types_are_ignored() + { + var result = TotalsPolicy.Classify(Meters(), [.. Links(), new(Haus, Wasser)]); + + Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class); + Assert.Empty(result.For(Wasser).ParentIds); + } + + [Fact] + public void A_containment_cycle_is_reported_and_neither_meter_becomes_a_root() + { + List meters = + [ + Physical(30, "A", Water, MeterMode.CumulativeCounter, "m³"), + Physical(31, "B", Water, MeterMode.CumulativeCounter, "m³"), + ]; + + var result = TotalsPolicy.Classify(meters, [new(30, 31), new(31, 30)]); + + Assert.Equal(MeterTotalsClass.Breakdown, result.For(30).Class); + Assert.Equal(MeterTotalsClass.Breakdown, result.For(31).Class); + Assert.Contains(new TotalsProblem(TotalsProblemKind.ContainmentCycle, 30), result.Problems); + Assert.Contains(new TotalsProblem(TotalsProblemKind.ContainmentCycle, 31), result.Problems); + Assert.Equal(BillingBasis.None, result.ForType(Water).Billing.Basis); + } + + [Fact] + public void A_total_load_linked_below_another_consumption_meter_is_reported_and_the_parent_is_not_counted() + { + var meters = Meters(); + meters.Add(Physical(10, "Main breaker", Electricity, MeterMode.CumulativeCounter, "kWh")); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]); + + Assert.Contains(new TotalsProblem(TotalsProblemKind.TotalLoadIsContained, Haus, 10), result.Problems); + Assert.Equal(MeterTotalsClass.NotCounted, result.For(10).Class); + Assert.Equal(MeterTotalsReason.ContainsTotalLoad, result.For(10).Reason); + Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + } + + [Fact] + public void A_type_with_only_generation_bills_nothing() + { + var result = TotalsPolicy.Classify([Physical(40, "Balcony PV", Electricity, MeterMode.GenerationCounter, "kWh")], []); + + Assert.Equal(BillingBasis.None, result.ForType(Electricity).Billing.Basis); + Assert.Equal([40], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation)); + Assert.Empty(result.BillItems); + + var unknown = result.ForType(99); + Assert.Empty(unknown.Measures); + Assert.Equal(BillingBasis.None, unknown.Billing.Basis); + } + + [Fact] + public void Validate_needs_a_known_meter_and_classify_rejects_duplicate_ids() + { + Assert.Throws(() => TotalsPolicy.Validate(Meters(), Links(), 404, TotalsOverride.Always)); + Assert.Throws(() => TotalsPolicy.Classify([.. Meters(), Meters()[0]], Links())); + } + + [Theory] + [InlineData(null, TotalsOverride.Auto)] + [InlineData("", TotalsOverride.Auto)] + [InlineData("auto", TotalsOverride.Auto)] + [InlineData("always", TotalsOverride.Always)] + [InlineData(" Never ", TotalsOverride.Never)] + [InlineData("sometimes", TotalsOverride.Auto)] + public void Override_tokens_parse_leniently_and_default_to_auto(string? token, TotalsOverride expected) => + Assert.Equal(expected, TotalsOverrideTokens.Parse(token)); + + [Fact] + public void Override_is_read_from_meter_meta_and_round_trips_its_token() + { + Assert.Equal(TotalsOverride.Always, TotalsOverrideTokens.FromMeta("""{"role":"total_load","totals":"always"}""")); + Assert.Equal(TotalsOverride.Auto, TotalsOverrideTokens.FromMeta("not json")); + Assert.Equal(TotalsOverride.Auto, TotalsOverrideTokens.FromMeta("""{"totals":1}""")); + foreach (var value in Enum.GetValues()) + { + Assert.Equal(value, TotalsOverrideTokens.Parse(TotalsOverrideTokens.ToToken(value))); + } + } + + [Fact] + public void Measures_group_units_by_their_canonical_spelling() + { + List meters = + [ + Physical(30, "Main", Water, MeterMode.CumulativeCounter, "m3"), + Physical(31, "Well", Water, MeterMode.CumulativeCounter, "m³"), + ]; + + var groups = TotalsPolicy.Classify(meters, []).ForType(Water).GroupsOf(TotalsMeasure.Use); + + Assert.Equal([new MeasureGroup(TotalsMeasure.Use, "m³", [30, 31])], groups, MeasureGroupComparer.Instance); + } + + [Theory] + [InlineData(Netz)] + [InlineData(Solar1)] + public void Always_on_a_consumption_sum_over_a_supply_meter_is_refused_naming_it(int source) + { + // m(Netz) as consumption would add the grid's energy to household use, which Haus already holds; m(Solar 1) + // as consumption would add generation to use. + var meters = Meters(); + meters.Add(Virtual(10, "Grid as use", Electricity, "kWh", QuantityKind.Consumption, [source], pureSum: true)); + + var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.SourceInOtherMeasure, source), check.Conflict); + + meters[^1] = meters[^1] with { Override = TotalsOverride.Always }; + var result = TotalsPolicy.Classify(meters, Links()); + Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation)); + } + + [Fact] + public void Always_on_a_virtual_without_a_declared_result_kind_is_not_additive() + { + // A legacy Summe Solar whose definition is not usable yet: its kind falls back to consumption in D-20, but that + // is a display assumption — counting it would add PV generation to household use. + var meters = MetersWith(SummeSolar, m => m with { Kind = QuantityKind.Consumption, VirtualResultKind = null, Override = TotalsOverride.Always }); + + var result = TotalsPolicy.Classify(meters, Links()); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAdditive, null), result.For(SummeSolar).RefusedOverride); + Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation)); + Assert.Equal( + TotalsConflictReason.NotAdditive, + TotalsPolicy.Validate(MetersWith(SummeSolar, m => m with { VirtualResultKind = null }), Links(), SummeSolar, TotalsOverride.Always).Conflict!.Reason); + } + + [Fact] + public void An_always_beside_a_counted_total_load_is_refused_naming_the_total_load() + { + // Main breaker → Haus and Main breaker → Workshop: the workshop is no subsection of Haus by link, but Haus is + // the total load — everything used is already in it, so counting the workshop as well double-counts. + var meters = Meters(); + meters.Add(Physical(10, "Main breaker", Electricity, MeterMode.CumulativeCounter, "kWh")); + meters.Add(Physical(11, "Workshop", Electricity, MeterMode.CumulativeCounter, "kWh")); + List links = [.. Links(), new(10, Haus), new(10, 11)]; + + var check = TotalsPolicy.Validate(meters, links, 11, TotalsOverride.Always); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict); + } + + [Fact] + public void An_always_sum_over_meters_retired_before_the_total_load_was_installed_is_allowed() + { + // Nothing was inside the total load before it existed, so a sum over the older meters adds nothing twice. + var meters = MetersWith(Haus, m => m with { InstalledAt = new DateOnly(2020, 1, 1) }); + meters.Add(Physical(10, "Old house", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31))); + meters.Add(Physical(11, "Old annex", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31))); + meters.Add(Virtual(12, "Old total", Electricity, "kWh", QuantityKind.Consumption, [10, 11], pureSum: true)); + + var check = TotalsPolicy.Validate(meters, Links(), 12, TotalsOverride.Always); + + Assert.True(check.IsAllowed); + } + + [Fact] + public void Always_on_a_sum_listing_a_meter_twice_is_refused_as_overlapping() + { + var meters = Meters(); + meters.Add(Virtual(10, "Solar 1 twice", Electricity, "kWh", QuantityKind.Generation, [Solar1, Solar1], pureSum: true, totals: TotalsOverride.Always)); + + var result = TotalsPolicy.Classify(meters, Links()); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, Solar1), result.For(10).RefusedOverride); + Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation)); + } + + [Fact] + public void Always_on_nested_sums_sharing_a_meter_is_refused_as_overlapping() + { + // A = m30 + m31, B = m31 + m32, C = A + B: the expansion keeps multiplicity, so m31 appears twice. + List meters = + [ + Physical(30, "House", Water, MeterMode.CumulativeCounter, "m³"), + Physical(31, "Garden", Water, MeterMode.CumulativeCounter, "m³"), + Physical(32, "Pool", Water, MeterMode.CumulativeCounter, "m³"), + Virtual(40, "C", Water, "m³", QuantityKind.Consumption, [30, 31, 31, 32], pureSum: true), + ]; + + var check = TotalsPolicy.Validate(meters, [], 40, TotalsOverride.Always); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, 31), check.Conflict); + } + + [Fact] + public void Always_on_a_sum_with_a_source_in_another_energy_type_is_refused_naming_it() + { + // Counted in electricity as an m³ group, while water still counts and bills the same meter. + List meters = + [ + Physical(30, "Water", Water, MeterMode.CumulativeCounter, "m³"), + Virtual(40, "Water in electricity", Electricity, "m³", QuantityKind.Consumption, [30], pureSum: true, totals: TotalsOverride.Always), + ]; + + var result = TotalsPolicy.Classify(meters, []); + + Assert.Equal(new TotalsConflict(TotalsConflictReason.SourceInOtherEnergyType, 30), result.For(40).RefusedOverride); + Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.Equal([30], result.ForType(Water).Billing.BilledMeterIds); + } + + [Fact] + public void Never_is_always_allowed_even_when_it_lets_a_lower_id_always_push_another_out() + { + // Water 30 and 31, meter 3 below 30 set to Always, and 40 = 30 + 31 set to Always. Today 3 is refused (30 + // counts) and 40 replaces 30 and 31. Excluding 30 lets 3 apply first, which then overlaps 40. Excluding a meter + // never counts anything twice, so the save goes through and the displaced override is reported instead. + List meters = + [ + Physical(3, "Kitchen", Water, MeterMode.CumulativeCounter, "m³", totals: TotalsOverride.Always), + Physical(30, "House", Water, MeterMode.CumulativeCounter, "m³"), + Physical(31, "Garden", Water, MeterMode.CumulativeCounter, "m³"), + Virtual(40, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always), + ]; + List links = [new(30, 3)]; + + var check = TotalsPolicy.Validate(meters, links, 30, TotalsOverride.Never); + + Assert.True(check.IsAllowed); + + var result = TotalsPolicy.Classify([.. meters.Select(m => m.Id == 30 ? m with { Override = TotalsOverride.Never } : m)], links); + Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(3).Class); + Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, 3), result.For(40).RefusedOverride); + Assert.Contains(new TotalsProblem(TotalsProblemKind.OverrideRefused, 40, 3, Conflict: TotalsConflictReason.OverlapsCountedMeter), result.Problems); + } + + [Fact] + public void A_retired_grid_meter_keeps_its_role_beside_its_replacement_and_both_are_billed() + { + // The grid meter was replaced by a new meter record on 31 Jan 2023. Both hold grid_import (A-07); each counts + // only within its own service period, so the bill before the replacement is not lost and the old meter's link + // into Haus stays a supply edge instead of turning Haus into a contained total load. + var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 1, 31) }); + meters.Add(Physical(10, "Zähler Netz (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 31))); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]); + + Assert.Empty(result.Problems); + Assert.Empty(result.Hints); + Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class); + Assert.Equal(MeterTotalsClass.GridImport, result.For(10).Class); + Assert.Equal([Netz, 10], result.ForType(Electricity).Billing.BilledMeterIds); + Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class); + Assert.Empty(result.For(Haus).ParentIds); + } + + [Fact] + public void Grid_meters_in_service_at_the_same_time_are_duplicates_whatever_their_dates() + { + var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 3, 31) }); + meters.Add(Physical(10, "Second grid meter", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 1))); + + var result = TotalsPolicy.Classify(meters, Links()); + + Assert.Equal(MeterTotalsReason.DuplicateRole, result.For(10).Reason); + Assert.Contains(new TotalsProblem(TotalsProblemKind.DuplicateRole, 10, Netz, MeterRole.GridImport), result.Problems); + Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds); + } + + [Fact] + public void An_unlinked_meter_from_before_the_total_load_was_installed_is_a_root_of_its_own() + { + // The house meter only exists since 2020; the old workshop meter was retired before, so no total load ever + // measured it and it counts in household use for its own period. A meter in service with the house meter is + // still taken to be inside it. + var meters = MetersWith(Haus, m => m with { InstalledAt = new DateOnly(2020, 1, 1) }); + meters.Add(Physical(10, "Old workshop", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31))); + meters.Add(Physical(11, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh")); + + var result = TotalsPolicy.Classify(meters, Links()); + + Assert.Equal(MeterTotalsReason.ConsumptionRoot, result.For(10).Reason); + Assert.Equal(MeterTotalsReason.AssumedInsideTotalLoad, result.For(11).Reason); + Assert.Equal([Haus, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.DoesNotContain(result.Hints, h => h.MeterId == 10); + } + + [Fact] + public void An_unlinked_meter_spanning_a_total_load_replacement_is_assumed_inside_both() + { + var meters = MetersWith(Haus, m => m with { RetiredAt = new DateOnly(2023, 1, 31) }); + meters.Add(Physical(10, "Zähler Haus (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.TotalLoad, installedAt: new DateOnly(2023, 1, 31))); + meters.Add(Physical(11, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh")); + + var result = TotalsPolicy.Classify(meters, [.. Links(), new(Netz, 10)]); + + Assert.Equal([Haus, 10], result.For(11).ParentIds); + Assert.Equal(Haus, result.For(11).RelatedMeterId); + Assert.Equal([Haus, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Use)); + Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole); + Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), TotalsPolicy.Validate(meters, [.. Links(), new(Netz, 10)], 11, TotalsOverride.Always).Conflict); + } + + [Fact] + public void A_grid_meter_and_a_total_load_never_in_service_together_raise_no_overlap_hint() + { + var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2019, 12, 31) }); + meters = [.. meters.Select(m => m.Id == Haus ? m with { InstalledAt = new DateOnly(2020, 1, 1) } : m)]; + var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).ToList(); + + var result = TotalsPolicy.Classify(meters, links); + + Assert.DoesNotContain(result.Hints, h => h.Kind == OverlapHintKind.GridImportNotLinkedToTotalLoad); + } + + private sealed class MeasureGroupComparer : IEqualityComparer + { + public static readonly MeasureGroupComparer Instance = new(); + + public bool Equals(MeasureGroup? x, MeasureGroup? y) => + x is not null && y is not null && x.Measure == y.Measure && x.Unit == y.Unit && x.MeterIds.SequenceEqual(y.MeterIds); + + public int GetHashCode(MeasureGroup obj) => HashCode.Combine(obj.Measure, obj.Unit); + } + + private sealed class EntryComparer : IEqualityComparer + { + public static readonly EntryComparer Instance = new(); + + public bool Equals(MeterTotalsEntry? x, MeterTotalsEntry? y) => + x is not null && y is not null + && (x.MeterId, x.EnergyTypeId, x.Class, x.Reason, x.Natural, x.Measure, x.RelatedMeterId) + == (y.MeterId, y.EnergyTypeId, y.Class, y.Reason, y.Natural, y.Measure, y.RelatedMeterId) + && Equals(x.RefusedOverride, y.RefusedOverride) + && x.ParentIds.SequenceEqual(y.ParentIds) + && x.ReplacesIds.SequenceEqual(y.ReplacesIds); + + public int GetHashCode(MeterTotalsEntry obj) => obj.MeterId; + } +} diff --git a/tests/Core.Tests/Analysis/TotalsSeed.cs b/tests/Core.Tests/Analysis/TotalsSeed.cs new file mode 100644 index 0000000..c2f2a09 --- /dev/null +++ b/tests/Core.Tests/Analysis/TotalsSeed.cs @@ -0,0 +1,105 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Totals; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// The reference data's topology as the totals policy sees it (ReferenceDataImporter): five electricity meters +/// and the Summe Solar view, the water meter, and the oil tank with its burner. Ids follow the seed's creation +/// order; the energy types are electricity 1, water 2, heating oil 3. +/// +/// +/// Roles are given as the stored tokens and turned into through +/// , exactly as the reader does (A-07) — so a test that stores +/// "GRID_IMPORT" or a role on a tank sees what production would. +/// +internal static class TotalsSeed +{ + public const int Electricity = 1; + public const int Water = 2; + public const int Oil = 3; + + public const int Haus = 1; + public const int Netz = 2; + public const int Auto = 3; + public const int Solar1 = 4; + public const int Solar2 = 5; + public const int Wasser = 6; + public const int Oeltank = 7; + public const int Brenner = 8; + public const int SummeSolar = 9; + + /// Solar 1 → Summe, Solar 2 → Summe, Netz → Haus, Summe → Haus, Haus → Auto. + public static List Links() => + [ + new(Solar1, SummeSolar), + new(Solar2, SummeSolar), + new(Netz, Haus), + new(SummeSolar, Haus), + new(Haus, Auto), + ]; + + public static List Meters() => + [ + Physical(Haus, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.TotalLoad), + Physical(Netz, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport), + Physical(Auto, "Zähler Auto", Electricity, MeterMode.CumulativeCounter, "kWh"), + Physical(Solar1, "Zähler Solar 1", Electricity, MeterMode.GenerationCounter, "kWh"), + Physical(Solar2, "Zähler Solar 2", Electricity, MeterMode.GenerationCounter, "kWh"), + Physical(Wasser, "Zähler Wasser", Water, MeterMode.CumulativeCounter, "m³"), + Physical(Oeltank, "Öltank", Oil, MeterMode.ConsumableBalance, "L"), + Physical(Brenner, "Brenner", Oil, MeterMode.RuntimeCounter, "h"), + Virtual(SummeSolar, "Summe Solar", Electricity, "kWh", QuantityKind.Generation, [Solar1, Solar2], pureSum: true), + ]; + + /// The seed with applied to the meter . + public static List MetersWith(int id, Func adjust) => + [.. Meters().Select(m => m.Id == id ? adjust(m) : m)]; + + public static List MetersWithOverride(params (int Id, TotalsOverride Override)[] overrides) => + [.. Meters().Select(m => overrides.Any(o => o.Id == m.Id) ? m with { Override = overrides.First(o => o.Id == m.Id).Override } : m)]; + + /// The stored role token; the meter gets the effective role, as the reader gives it. + public static TotalsMeter Physical( + int id, + string name, + int energyTypeId, + MeterMode mode, + string unit, + string? role = null, + TotalsOverride totals = TotalsOverride.Auto, + QuantityKind? kind = null, + DateOnly? installedAt = null, + DateOnly? retiredAt = null) + { + var effective = MeterRoleRules.Effective(mode, role); + return new(id, name, energyTypeId, mode, effective, kind ?? KindOf(mode, effective), unit, false, totals, installedAt, retiredAt, + [], false, null); + } + + /// The declared result kind; null for a definition that is not usable. + /// A stored role token. A virtual meter never holds one (A-07), so it always reads as none. + public static TotalsMeter Virtual( + int id, + string name, + int energyTypeId, + string unit, + QuantityKind? resultKind, + IReadOnlyList sources, + bool pureSum, + string? role = null, + TotalsOverride totals = TotalsOverride.Auto) => + new(id, name, energyTypeId, MeterMode.Virtual, MeterRoleRules.Effective(MeterMode.Virtual, role), + resultKind ?? QuantityKind.Consumption, unit, true, totals, null, null, sources, pureSum, resultKind); + + // Mirrors NormalizedQuantity: an effective grid_export role makes a flow meter measure export. + private static QuantityKind KindOf(MeterMode mode, MeterRole? role) => mode switch + { + MeterMode.GenerationCounter => QuantityKind.Generation, + MeterMode.RuntimeCounter => QuantityKind.Runtime, + _ when role == MeterRole.GridExport => QuantityKind.Export, + _ => QuantityKind.Consumption, + }; +} diff --git a/tests/Core.Tests/Analysis/UnitsTests.cs b/tests/Core.Tests/Analysis/UnitsTests.cs new file mode 100644 index 0000000..dbbd8f6 --- /dev/null +++ b/tests/Core.Tests/Analysis/UnitsTests.cs @@ -0,0 +1,349 @@ +using MeterVault.Core.Analysis.Quantities; + +namespace MeterVault.Core.Tests.Analysis; + +public sealed class UnitsTests +{ + public static TheoryData AliasTableEntries + { + get + { + var data = new TheoryData(); + foreach (var entry in Units.AliasTable) + { + data.Add(entry.Alias, entry.Info.Symbol, entry.CaseSensitive); + } + + return data; + } + } + + [Theory] + // Energy + [InlineData("kWh", "kWh")] + [InlineData("kwh", "kWh")] + [InlineData("KWH", "kWh")] + [InlineData("KWh", "kWh")] + [InlineData(" kWh ", "kWh")] + [InlineData("k Wh", "kWh")] + [InlineData("Kilowattstunden", "kWh")] + [InlineData("kilowatt-hour", "kWh")] + [InlineData("Kilowatt hours", "kWh")] + [InlineData("Wh", "Wh")] + [InlineData("wh", "Wh")] + [InlineData("Wattstunden", "Wh")] + [InlineData("MWh", "MWh")] + [InlineData("MWH", "MWh")] + [InlineData("Mwh", "MWh")] + [InlineData("Megawattstunde", "MWh")] + [InlineData("mWh", "mWh")] + [InlineData("GWh", "GWh")] + [InlineData("MJ", "MJ")] + [InlineData("gj", "GJ")] + // Power + [InlineData("W", "W")] + [InlineData("w", "W")] + [InlineData("Watt", "W")] + [InlineData("kW", "kW")] + [InlineData("kw", "kW")] + [InlineData("KW", "kW")] + [InlineData("Kilowatt", "kW")] + [InlineData("MW", "MW")] + [InlineData("Mw", "MW")] + [InlineData("mW", "mW")] + // Volume + [InlineData("m3", "m³")] + [InlineData("M3", "m³")] + [InlineData("m³", "m³")] + [InlineData("cbm", "m³")] + [InlineData("CBM", "m³")] + [InlineData("m^3", "m³")] + [InlineData("Kubikmeter", "m³")] + [InlineData("cubic metre", "m³")] + [InlineData("l", "L")] + [InlineData("L", "L")] + [InlineData("Liter", "L")] + [InlineData("liter", "L")] + [InlineData("Litre", "L")] + [InlineData("litres", "L")] + [InlineData("ltr", "L")] + [InlineData("dm3", "L")] + [InlineData("hl", "hL")] + [InlineData("Hektoliter", "hL")] + // Time + [InlineData("h", "h")] + [InlineData("H", "h")] + [InlineData("hour", "h")] + [InlineData("hours", "h")] + [InlineData("hrs", "h")] + [InlineData("Std", "h")] + [InlineData("Std.", "h")] + [InlineData("Stunden", "h")] + [InlineData("Betriebsstunden", "h")] + [InlineData("min", "min")] + [InlineData("Minuten", "min")] + [InlineData("s", "s")] + // Mass + [InlineData("kg", "kg")] + [InlineData("KG", "kg")] + [InlineData("t", "t")] + [InlineData("Tonne", "t")] + public void Every_alias_normalizes_to_its_canonical_symbol(string alias, string canonical) + { + Assert.Equal(canonical, Units.Normalize(alias)); + } + + [Theory] + [MemberData(nameof(AliasTableEntries))] + public void Every_entry_of_the_alias_table_names_its_own_unit_however_it_is_capitalised( + string alias, string symbol, bool caseSensitive) + { + var info = Units.Describe(symbol); + + Assert.NotNull(info); + Assert.Equal(symbol, info.Symbol); + Assert.Equal(symbol, Units.Normalize(symbol)); + Assert.Equal(symbol, Units.Normalize(alias)); + Assert.Equal(info, Units.Describe(alias)); + Assert.Equal(info, Units.Describe($" {alias} ")); + if (!caseSensitive) + { + // Typing a unit in capitals never changes what it means ("KWH", "LITER", "M³"). + Assert.Equal(info, Units.Describe(alias.ToUpperInvariant())); + } + } + + [Fact] + public void The_alias_table_holds_every_canonical_symbol_once_per_spelling() + { + var spellings = Units.AliasTable.Select(a => a.Alias).ToList(); + + Assert.Equal(spellings.Count, spellings.Distinct(StringComparer.Ordinal).Count()); + Assert.All( + new[] { "mWh", "Wh", "kWh", "MWh", "GWh", "MJ", "GJ", "mW", "W", "kW", "MW", "GW", "L", "hL", "m³", "g", "kg", "t", "h", "min", "s" }, + symbol => Assert.Contains(symbol, spellings)); + } + + [Fact] + public void Milli_and_mega_are_told_apart_by_the_case_of_the_m() + { + // Home Assistant reports small sensors in mW/mWh; reading them as MW/MWh would be off by 10⁹. + Assert.Equal(new UnitInfo("mW", UnitDimension.Power, 0.000_001), Units.Describe("mW")); + Assert.Equal(new UnitInfo("MW", UnitDimension.Power, 1_000), Units.Describe("MW")); + Assert.Equal(new UnitInfo("mWh", UnitDimension.Energy, 0.000_001), Units.Describe("mWh")); + Assert.Equal(new UnitInfo("MWh", UnitDimension.Energy, 1_000), Units.Describe("MWh")); + Assert.Equal(0.000_001, Units.ConversionFactor("mWh", "kWh")!.Value, 12); + Assert.False(Units.AreSame("mW", "MW")); + } + + [Theory] + [InlineData("mwh")] + [InlineData("mw")] + [InlineData("mj")] + [InlineData("mWH")] + public void A_lower_case_m_on_a_mega_symbol_is_not_guessed(string unit) + { + Assert.Null(Units.Describe(unit)); + Assert.Null(Units.ConversionFactor(unit, "kWh")); + Assert.Null(Units.ConversionFactor(unit, "kW")); + } + + [Theory] + [InlineData(" Stk ", "stk")] + [InlineData("STK", "stk")] + [InlineData("Pellets", "pellets")] + [InlineData("%", "%")] + [InlineData("100 L", "100 l")] + [InlineData("kWh (el)", "kwh (el)")] + [InlineData("Nm³", "nm³")] + public void An_unknown_unit_is_trimmed_and_folded_so_every_spelling_gives_one_key(string unit, string expected) + { + Assert.Equal(expected, Units.Normalize(unit)); + Assert.Null(Units.Describe(unit)); + } + + [Fact] + public void Normalized_units_can_be_grouped_ordinally() + { + var meters = new[] { "Stk", "stk", "STK", "m3", "m³", "cbm", "kWh", "KWH" }; + + var groups = meters.GroupBy(Units.Normalize, StringComparer.Ordinal).Select(g => (g.Key, g.Count())).ToList(); + + Assert.Equal([("stk", 3), ("m³", 3), ("kWh", 2)], groups); + } + + [Fact] + public void The_unit_comparer_treats_every_spelling_of_a_unit_as_one_key() + { + var comparer = Units.Comparer; + + Assert.True(comparer.Equals("m3", "m³")); + Assert.True(comparer.Equals("Stk", "stk")); + Assert.True(comparer.Equals(null, " ")); + Assert.False(comparer.Equals("kWh", "MWh")); + Assert.False(comparer.Equals("mW", "MW")); + Assert.Equal(comparer.GetHashCode("Stk"), comparer.GetHashCode("STK")); + Assert.Equal(comparer.GetHashCode("m3"), comparer.GetHashCode("cbm")); + Assert.Equal(3, new[] { "m3", "m³", "Stk", "stk", "kWh", "kwh" }.Distinct(comparer).Count()); + + var byUnit = new Dictionary(comparer) { ["m3"] = 1 }; + Assert.True(byUnit.ContainsKey("Kubikmeter")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void A_missing_unit_normalizes_to_empty(string? unit) + { + Assert.Equal(string.Empty, Units.Normalize(unit)); + } + + [Theory] + [InlineData("m3/h", "m³/h")] + [InlineData("l/h", "L/h")] + [InlineData("Liter / Std", "L/h")] + [InlineData("kWh / h", "kWh/h")] + [InlineData("Stk/h", "stk/h")] + public void A_rate_normalizes_both_sides(string unit, string expected) + { + Assert.Equal(expected, Units.Normalize(unit)); + } + + [Theory] + [InlineData("m3", "m³")] + [InlineData("cbm", "M3")] + [InlineData("Liter", "l")] + [InlineData("Stk", "stk")] + [InlineData("L/h", "l/Std")] + [InlineData("Kilowattstunden", "kWh")] + public void Different_spellings_of_one_unit_are_the_same(string a, string b) + { + Assert.True(Units.AreSame(a, b)); + } + + [Fact] + public void Units_that_only_convert_into_each_other_are_not_the_same() + { + Assert.False(Units.AreSame("kWh", "MWh")); + Assert.False(Units.AreSame("L", "m³")); + } + + [Theory] + [InlineData("kWh", "MWh")] + [InlineData("Wh", "kWh")] + [InlineData("L", "m3")] + [InlineData("hl", "L")] + [InlineData("kW", "MW")] + [InlineData("h", "min")] + [InlineData("kg", "t")] + [InlineData("GJ", "kWh")] + [InlineData("Stk", "Stk")] + [InlineData("m3/h", "L/min")] + [InlineData("mWh", "kWh")] + public void Units_of_one_dimension_are_compatible(string a, string b) + { + Assert.True(Units.AreCompatible(a, b)); + Assert.True(Units.AreCompatible(b, a)); + } + + [Theory] + [InlineData("kWh", "m³")] + [InlineData("kW", "kWh")] + [InlineData("h", "L")] + [InlineData("kg", "L")] + [InlineData("Stk", "kWh")] + [InlineData("Stk", "Pellets")] + [InlineData("", "kWh")] + [InlineData("", "")] + [InlineData("m³/h", "m³")] + public void Units_of_different_dimensions_are_not_compatible(string a, string b) + { + Assert.False(Units.AreCompatible(a, b)); + Assert.Null(Units.ConversionFactor(a, b)); + } + + [Theory] + [InlineData("MWh", "kWh", 1000)] + [InlineData("kWh", "MWh", 0.001)] + [InlineData("Wh", "kWh", 0.001)] + [InlineData("kWh", "kWh", 1)] + [InlineData("m3", "L", 1000)] + [InlineData("L", "m³", 0.001)] + [InlineData("hL", "L", 100)] + [InlineData("L", "hL", 0.01)] + [InlineData("t", "kg", 1000)] + [InlineData("min", "h", 1 / 60d)] + [InlineData("GJ", "kWh", 1000 / 3.6)] + [InlineData("MJ", "kWh", 1 / 3.6)] + [InlineData("Stk", "stk", 1)] + [InlineData("m³/h", "L/min", 1000 / 60d)] + [InlineData("Kilowattstunden", "Wh", 1000)] + public void The_conversion_factor_expresses_an_amount_in_the_target_unit(string from, string to, double factor) + { + Assert.Equal(factor, Units.ConversionFactor(from, to)!.Value, 9); + } + + [Theory] + [InlineData("W", "Wh")] + [InlineData("kW", "kWh")] + [InlineData("kw", "kWh")] + [InlineData("MW", "MWh")] + [InlineData("GW", "GWh")] + [InlineData("mW", "mWh")] + [InlineData("L/h", "L")] + [InlineData("m3/h", "m³")] + [InlineData("l/Std", "L")] + [InlineData("kWh/h", "kWh")] + [InlineData("W/m²", "Wh/m²")] + [InlineData("kW/m2", "kWh/m2")] + [InlineData("kWh", "kWh")] + [InlineData("L", "L")] + [InlineData("Stk", "stk")] + [InlineData("", "")] + public void A_rate_per_hour_integrates_over_hours_to_its_quantity_unit(string rateUnit, string integrated) + { + Assert.Equal(integrated, Units.IntegratedOverHours(rateUnit)); + } + + [Theory] + [InlineData("L/min", "L/min")] + [InlineData("l/Minute", "L/min")] + [InlineData("m³/s", "m³/s")] + [InlineData("L/Tag", "L/tag")] + public void A_rate_over_another_time_keeps_its_rate_unit_because_the_normalizer_does_not_rescale_it( + string rateUnit, string kept) + { + // The normalizer books value × elapsed hours: 10 L/min for an hour books 10, which is not 10 L. + Assert.Equal(kept, Units.IntegratedOverHours(rateUnit)); + Assert.Null(Units.ConversionFactor(Units.IntegratedOverHours(rateUnit), "L")); + } + + [Theory] + [InlineData("kW", true)] + [InlineData("W", true)] + [InlineData("mW", true)] + [InlineData("L/h", true)] + [InlineData("m3/Std", true)] + [InlineData("W/m²", true)] + [InlineData("L/min", false)] + [InlineData("m³/s", false)] + [InlineData("kWh", false)] + [InlineData("Stk", false)] + [InlineData("", false)] + public void Only_power_and_explicit_per_hour_units_are_stated_per_hour_rates(string rateUnit, bool perHour) + { + Assert.Equal(perHour, Units.IsPerHourRate(rateUnit)); + } + + [Fact] + public void Describe_reports_dimension_and_scale_against_the_reference_unit() + { + var mwh = Units.Describe("MWH")!; + var cubic = Units.Describe("cbm")!; + + Assert.Equal(new UnitInfo("MWh", UnitDimension.Energy, 1000), mwh); + Assert.Equal(new UnitInfo("m³", UnitDimension.Volume, 1000), cubic); + Assert.Null(Units.Describe("L/h")); + } +} diff --git a/tests/Core.Tests/Analysis/VirtualDefinitionJsonTests.cs b/tests/Core.Tests/Analysis/VirtualDefinitionJsonTests.cs new file mode 100644 index 0000000..2c29d36 --- /dev/null +++ b/tests/Core.Tests/Analysis/VirtualDefinitionJsonTests.cs @@ -0,0 +1,250 @@ +using System.Text.Json; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Quantities; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// A virtual meter's definition lives in Meter.Meta next to other keys such as role (D-25). Writing it +/// must never lose those keys, the referenced ids must follow the expression rather than whatever was stored, and +/// reading must never throw — a bad blob becomes a "malformed" result for that one meter. +/// +public sealed class VirtualDefinitionJsonTests +{ + private static readonly VirtualDefinition SummeSolar = new("m4 + m5", QuantityKind.Generation, "kWh", VirtualCostRule.None); + + [Fact] + public void A_definition_round_trips_and_keeps_the_role_and_every_other_key() + { + const string existing = """{"role":"grid_import","custom":{"a":[1,2]},"note":"keep me"}"""; + + var written = VirtualDefinitionJson.Write(existing, SummeSolar); + var read = VirtualDefinitionJson.Read(written); + + Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status); + Assert.Equal(SummeSolar, read.Definition); + Assert.False(read.ReferencedIdsStale); + Assert.Equal(MeterRoles.GridImport, MeterMeta.Role(written)); + Assert.Equal("keep me", MeterMeta.ReadString(written, "note")); + using var doc = JsonDocument.Parse(written); + Assert.Equal("[1,2]", doc.RootElement.GetProperty("custom").GetProperty("a").GetRawText()); + } + + [Fact] + public void Written_keys_use_the_documented_tokens_and_derive_the_referenced_ids() + { + using var doc = JsonDocument.Parse(VirtualDefinitionJson.Write("{}", SummeSolar)); + var root = doc.RootElement; + + Assert.Equal("m4 + m5", root.GetProperty("expression").GetString()); + Assert.Equal("[4,5]", root.GetProperty("referencedMeterIds").GetRawText()); + Assert.Equal("generation", root.GetProperty("resultKind").GetString()); + Assert.Equal("kWh", root.GetProperty("resultUnit").GetString()); + Assert.Equal("none", root.GetProperty("costRule").GetString()); + + using var consumption = JsonDocument.Parse(VirtualDefinitionJson.Write( + "{}", new VirtualDefinition("m1 + m3", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts))); + Assert.Equal("sourceCosts", consumption.RootElement.GetProperty("costRule").GetString()); + } + + [Fact] + public void Writing_a_definition_that_leaves_its_kind_or_cost_rule_to_inference_is_refused() + { + // A-08: what is stored is the effective definition. Storing "m4 + m5" without its kind would make every reader + // take Summe Solar for consumption unless it re-validated the whole catalog first. + const string meta = """{"role":"total_load"}"""; + + Assert.Throws(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m1 - m2"))); + Assert.Throws(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m1 - m2", QuantityKind.Consumption))); + Assert.Throws(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m9", QuantityKind.Runtime, "h", VirtualCostRule.None))); + } + + [Fact] + public void Saving_stores_the_inferred_kind_unit_and_cost_rule_so_readers_never_infer_them_again() + { + var catalog = new MeterCatalog(VirtualFixtures.SeededElectricity()); + var validation = VirtualValidator.Validate(new VirtualDefinition("m4 + m5"), 6, catalog); + + var read = VirtualDefinitionJson.Read(VirtualDefinitionJson.Write("""{"note":"x"}""", validation.EffectiveDefinition!)); + + Assert.Equal(SummeSolar, read.Definition); + var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, read.Definition!.DeclaredResult); + Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh"), quantity); + } + + [Fact] + public void The_unit_is_stored_in_canonical_spelling_and_a_blank_one_is_left_out() + { + using var water = JsonDocument.Parse(VirtualDefinitionJson.Write("{}", new VirtualDefinition("m7", QuantityKind.Consumption, " m3 ", VirtualCostRule.None))); + Assert.Equal("m³", water.RootElement.GetProperty("resultUnit").GetString()); + + using var unitless = JsonDocument.Parse(VirtualDefinitionJson.Write("""{"resultUnit":"kWh"}""", new VirtualDefinition("m7", QuantityKind.Consumption, null, VirtualCostRule.None))); + Assert.False(unitless.RootElement.TryGetProperty("resultUnit", out _)); + } + + [Theory] + [InlineData("runtime")] + [InlineData("export")] + [InlineData("cost")] + public void A_stored_kind_no_virtual_meter_may_have_is_malformed(string token) + { + var read = VirtualDefinitionJson.Read($$"""{"expression":"m1","resultKind":"{{token}}"}"""); + + Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status); + Assert.Null(read.Definition!.ResultKind); + } + + [Theory] + [InlineData("expression", "LONE")] + [InlineData("resultUnit", "LONEx")] + [InlineData("costRule", "LONE")] + public void Text_that_cannot_be_decoded_is_malformed_not_an_exception(string key, string value) + { + // A lone surrogate escape is valid JSON but no .NET string; jsonb refuses it, an import file does not. + var escaped = value.Replace("LONE", "\\ud800", StringComparison.Ordinal); + var meta = key == "expression" + ? $$"""{"expression":"{{escaped}}"}""" + : $$"""{"expression":"m1","{{key}}":"{{escaped}}"}"""; + + Assert.Equal(VirtualDefinitionReadStatus.Malformed, VirtualDefinitionJson.Read(meta).Status); + } + + [Fact] + public void Stored_referenced_ids_are_never_trusted_over_the_expression() + { + var read = VirtualDefinitionJson.Read("""{"expression":"m1 + m2","referencedMeterIds":[9]}"""); + + Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status); + Assert.Equal([1, 2], read.Definition!.ReferencedMeterIds); + Assert.True(read.ReferencedIdsStale); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + [InlineData("""{"role":"grid_import"}""")] + [InlineData("""{"expression":null}""")] + [InlineData("""{"expression":" "}""")] + public void Meta_without_an_expression_is_a_legacy_meter_not_an_error(string? meta) + { + var read = VirtualDefinitionJson.Read(meta); + + Assert.Equal(VirtualDefinitionReadStatus.Absent, read.Status); + Assert.Null(read.Definition); + } + + [Theory] + [InlineData("not json")] + [InlineData("{\"expression\":")] + [InlineData("[1,2,3]")] + [InlineData("\"m1 + m2\"")] + [InlineData("""{"expression":5}""")] + [InlineData("""{"expression":["m1"]}""")] + public void Unreadable_meta_is_malformed_and_never_throws(string meta) + { + var read = VirtualDefinitionJson.Read(meta); + + Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status); + Assert.NotNull(read.Problem); + } + + [Fact] + public void Json_nested_beyond_the_reader_depth_is_malformed_not_an_exception() + { + var deep = """{"expression":"m1","x":""" + new string('[', 200) + new string(']', 200) + "}"; + + Assert.Equal(VirtualDefinitionReadStatus.Malformed, VirtualDefinitionJson.Read(deep).Status); + } + + [Theory] + [InlineData("""{"expression":"m1","resultKind":"power"}""")] + [InlineData("""{"expression":"m1","resultKind":3}""")] + [InlineData("""{"expression":"m1","costRule":"cheap"}""")] + [InlineData("""{"expression":"m1","resultUnit":true}""")] + public void A_bad_optional_key_is_malformed_but_keeps_the_expression_for_repair(string meta) + { + var read = VirtualDefinitionJson.Read(meta); + + Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status); + Assert.Equal("m1", read.Definition!.Expression); + } + + [Fact] + public void A_syntax_error_is_the_validators_business_not_a_json_problem() + { + var read = VirtualDefinitionJson.Read("""{"expression":"m1 +"}"""); + + Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status); + Assert.False(read.Definition!.Parsed.Success); + Assert.Equal([1], read.Definition.ReferencedMeterIds); + } + + [Fact] + public void Tokens_are_read_case_insensitively() + { + var read = VirtualDefinitionJson.Read("""{"expression":"m1","resultKind":"Generation","costRule":"SOURCECOSTS","resultUnit":" kWh "}"""); + + Assert.Equal(new VirtualDefinition("m1", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts), read.Definition); + } + + [Fact] + public void Rewriting_meter_ids_follows_an_import_renumbering_and_keeps_the_role() + { + const string meta = """{"role":"total_load","expression":"m1 - m2","referencedMeterIds":[1,2],"resultKind":"consumption"}"""; + var map = new Dictionary { [1] = 11, [2] = 12 }; + + var rewritten = VirtualDefinitionJson.RewriteMeterIds(meta, id => map[id]); + var read = VirtualDefinitionJson.Read(rewritten); + + Assert.Equal("m11 - m12", read.Definition!.Expression); + Assert.Equal([11, 12], read.Definition.ReferencedMeterIds); + Assert.False(read.ReferencedIdsStale); + Assert.Equal(QuantityKind.Consumption, read.Definition.ResultKind); + Assert.Equal(MeterRoles.TotalLoad, MeterMeta.Role(rewritten)); + } + + [Theory] + [InlineData("""{"role":"grid_import"}""")] + [InlineData("not json")] + [InlineData("")] + public void Rewriting_meter_ids_leaves_meta_without_an_expression_untouched(string meta) + { + Assert.Equal(meta, VirtualDefinitionJson.RewriteMeterIds(meta, _ => throw new InvalidOperationException("no ids to map"))); + } + + [Fact] + public void Removing_a_definition_keeps_every_other_key() + { + var withDefinition = VirtualDefinitionJson.Write("""{"role":"grid_export"}""", SummeSolar); + + var removed = VirtualDefinitionJson.Remove(withDefinition); + + Assert.Equal(VirtualDefinitionReadStatus.Absent, VirtualDefinitionJson.Read(removed).Status); + Assert.Equal(MeterRoles.GridExport, MeterMeta.Role(removed)); + } + + [Theory] + [InlineData("[1,2]")] + [InlineData("not json")] + [InlineData("""{"a":1,"a":2}""")] + public void Meta_that_is_not_a_json_object_is_replaced_on_write(string meta) + { + var written = VirtualDefinitionJson.Write(meta, SummeSolar); + + Assert.Equal(SummeSolar, VirtualDefinitionJson.Read(written).Definition); + } + + [Fact] + public void Changing_the_expression_with_a_with_expression_reparses_it() + { + var changed = SummeSolar with { Expression = "m7 * 2" }; + + Assert.Equal([7], changed.ReferencedMeterIds); + Assert.Equal(Formula.Parse("m7 * 2"), changed.Formula); + Assert.Equal([4, 5], SummeSolar.ReferencedMeterIds); + } +} diff --git a/tests/Core.Tests/Analysis/VirtualEvaluatorTests.cs b/tests/Core.Tests/Analysis/VirtualEvaluatorTests.cs new file mode 100644 index 0000000..5a2c6d5 --- /dev/null +++ b/tests/Core.Tests/Analysis/VirtualEvaluatorTests.cs @@ -0,0 +1,516 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Virtual; +using static MeterVault.Core.Tests.Analysis.VirtualFixtures; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// Virtual meters evaluated on read (D-27), pinned on the brief's worked example (§5.4): A and B are generation +/// meters with complete monthly data — A 100/80 kWh, B 150/120 kWh in January/February. A missing source makes a +/// bucket unknown, never a confident partial number; an observed zero is a real input; non-finite arithmetic is +/// invalid, never zero; a non-additive formula's total is the formula over the totals. +/// +public sealed class VirtualEvaluatorTests +{ + /// The virtual meter being evaluated; every dependency path starts here. + private const int Self = 99; + + private const int A = 1; + private const int B = 2; + + private static readonly AnalysisBucket[] JanFeb = Months(2025, 1, 2); + + private static VirtualSource SourceA() => Source(A, Monthly((2025, 1, 100), (2025, 2, 80))); + + private static VirtualSource SourceB() => Source(B, Monthly((2025, 1, 150), (2025, 2, 120))); + + private static VirtualEvaluation Evaluate(string formula, params VirtualSource[] sources) => + Evaluate(formula, JanFeb, sources); + + private static VirtualEvaluation Evaluate( + string formula, IReadOnlyList buckets, IEnumerable sources, int meterId = Self, QuantityKind kind = QuantityKind.Generation) => + VirtualEvaluator.Evaluate(meterId, Formula.Parse(formula), kind, buckets, sources); + + private static BucketValue Unresolved(double? value = null) => + new(value, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution); + + [Fact] + public void A_plus_B_is_250_and_200_by_month_and_450_in_total() + { + var result = Evaluate("m1 + m2", SourceA(), SourceB()); + + Assert.Equal([250d, 200d], result.Values.Select(v => v.Value!.Value)); + Assert.All(result.Values, v => Assert.Equal(BucketStatus.Available, v.Status)); + Assert.Equal(450, result.Total.Value); + Assert.Equal(BucketStatus.Available, result.Total.Status); + Assert.True(result.IsAdditive); + Assert.Equal(Provenance.Derived | Provenance.Imported, result.Total.Provenance); + Assert.Equal(59, result.JointDays); + Assert.Equal(ResolutionClass.Month, result.Resolution); + Assert.Equal(Self, result.MeterId); + } + + [Fact] + public void Each_source_contributes_its_own_series_and_the_amounts_that_entered_the_formula() + { + var result = Evaluate("m1 + m2", SourceA(), SourceB()); + + var a = result.Contributions.Single(c => c.MeterId == A); + var b = result.Contributions.Single(c => c.MeterId == B); + Assert.Equal([100d, 80d], a.Values.Select(v => v.Value!.Value)); + Assert.Equal([150d, 120d], b.Values.Select(v => v.Value!.Value)); + Assert.Equal([100d, 80d], a.UsedAmounts.Select(v => v!.Value)); + Assert.Equal(1, a.Coefficient); + Assert.Equal(180, a.Total.Value); + Assert.Equal(270, b.UsedTotal); + } + + [Fact] + public void B_missing_in_February_makes_February_missing_not_a_confident_80() + { + var bOnlyJanuary = Source(B, Monthly((2025, 1, 150))); + + var result = Evaluate("m1 + m2", SourceA(), bOnlyJanuary); + + var february = result.Values[1]; + Assert.Equal(BucketStatus.Missing, february.Status); + Assert.Null(february.Value); + Assert.Equal(ValueIssue.MissingSource, february.Issue); + Assert.Equal([Self, B], february.DependencyPath!); + Assert.Null(february.IssueDetail); + + // The period total is the formula over the joint coverage — January only — and says so. A's February is a + // whole month outside the joint coverage, not a month cut in two, so the total stays a partial 250. + Assert.Equal(BucketStatus.Partial, result.Total.Status); + Assert.Equal(250, result.Total.Value); + Assert.Equal(new DateOnly(2025, 1, 31), result.LastJointDay); + + // The contribution table still shows A's own February and B's absence. + var a = result.Contributions.Single(c => c.MeterId == A); + var b = result.Contributions.Single(c => c.MeterId == B); + Assert.Equal(80, a.Values[1].Value); + Assert.Equal(BucketStatus.Missing, b.Values[1].Status); + Assert.Null(a.UsedAmounts[1]); + } + + [Fact] + public void B_observed_zero_in_February_makes_a_complete_80() + { + var bZeroInFebruary = Source(B, Monthly((2025, 1, 150), (2025, 2, 0))); + + var result = Evaluate("m1 + m2", SourceA(), bZeroInFebruary); + + Assert.Equal(BucketValue.Available(80, Provenance.Derived | Provenance.Imported), result.Values[1]); + Assert.Equal(330, result.Total.Value); + Assert.Equal(BucketStatus.Available, result.Total.Status); + } + + [Fact] + public void A_minus_B_is_minus_50_and_minus_40_and_stays_signed() + { + var result = Evaluate("m1 - m2", SourceA(), SourceB()); + + Assert.Equal([-50d, -40d], result.Values.Select(v => v.Value!.Value)); + Assert.Equal(-90, result.Total.Value); + Assert.True(result.IsAdditive); + Assert.Equal(-1, result.Contributions.Single(c => c.MeterId == B).Coefficient); + } + + [Fact] + public void A_source_covering_part_of_a_bucket_makes_it_partial_with_the_jointly_covered_value() + { + var feb1 = new DateOnly(2025, 2, 1); + var dailyA = Source(A, Daily(feb1, feb1.AddMonths(1), 1)); + var halfB = Source(B, Daily(feb1, feb1.AddDays(14), 2)); + + var result = Evaluate("m1 + m2", [Month(2025, 2)], [dailyA, halfB]); + + var february = Assert.Single(result.Values); + Assert.Equal(BucketStatus.Partial, february.Status); + Assert.Equal(14 + 28, february.Value); + Assert.Equal(ValueIssue.PartialCoverage, february.Issue); + Assert.Equal([Self, B], february.DependencyPath!); + Assert.Equal(ResolutionClass.Day, result.Resolution); + } + + [Theory] + [InlineData(10, 22)] // m2 covers 10–31 January: a full month of m1 against 22 days of m2 would read 78 + [InlineData(1, 15)] // m2 covers 1–15 January: m1's amount lies outside the joint days, so it would read −15 + public void A_monthly_source_cut_inside_its_month_by_another_sources_coverage_is_unresolved_not_a_wrong_partial(int firstDay, int dayCount) + { + var from = new DateOnly(2025, 1, firstDay); + var monthly = Source(A, Monthly((2025, 1, 100))); + var daily = Source(B, Daily(from, from.AddDays(dayCount), 1)); + + var result = Evaluate("m1 - m2", [Month(2025, 1)], [monthly, daily]); + + foreach (var value in new[] { result.Values[0], result.Total }) + { + Assert.Equal(BucketStatus.Unresolved, value.Status); + Assert.Equal(ValueIssue.CoarseResolution, value.Issue); + Assert.Null(value.Value); + Assert.Equal([Self, A], value.DependencyPath!); + } + + Assert.Null(result.Contributions.Single(c => c.MeterId == A).UsedAmounts[0]); + } + + [Theory] + [InlineData(true, BucketStatus.Partial)] + [InlineData(false, BucketStatus.Unresolved)] + public void A_monthly_source_may_be_cut_at_a_month_boundary_only_when_it_is_divided_at_months(bool dividedAtMonths, BucketStatus expected) + { + // m1 is monthly for January and February, m2 starts on 1 February. Divided at months, m1's February is exactly + // February's use and the two-month bucket is a partial 80 − 28. A tank read mid-month is not: its "February" + // amount belongs to an interval that starts in January, so the same cut is unresolved. + var monthly = Source(A, Monthly(dividedAtMonths, (2025, 1, 100), (2025, 2, 80))); + var daily = Source(B, Daily(new DateOnly(2025, 2, 1), new DateOnly(2025, 3, 1), 1)); + var janFeb = new AnalysisBucket(new DateOnly(2025, 1, 1), new DateOnly(2025, 3, 1), Utc(new DateOnly(2025, 1, 1)), Utc(new DateOnly(2025, 3, 1)), BucketSize.Year); + + var result = Evaluate("m1 - m2", [janFeb], [monthly, daily]); + + Assert.Equal(expected, result.Values[0].Status); + Assert.Equal(expected, result.Total.Status); + if (expected == BucketStatus.Partial) + { + Assert.Equal(80 - 28, result.Values[0].Value); + Assert.Equal([Self, B], result.Values[0].DependencyPath!); + } + else + { + Assert.Equal([Self, A], result.Values[0].DependencyPath!); + } + } + + [Fact] + public void A_quarterly_interval_cut_by_joint_coverage_leaves_the_period_total_unresolved() + { + // m1 books one quarterly reading (1 Jan – 31 Mar, 300) and reports its month buckets unresolved while resolving + // the quarter. m2 only starts in February, so the joint coverage keeps all 300 against two months of m2. + var quarter = Source(A, Coarse(new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 1), 300)) with + { + BucketStates = [Unresolved(), Unresolved(), Unresolved()], + PeriodState = BucketValue.Available(300, Provenance.Imported), + }; + var daily = Source(B, Daily(new DateOnly(2025, 2, 1), new DateOnly(2025, 4, 1), 1)); + + var result = Evaluate("m1 - m2", Months(2025, 1, 3), [quarter, daily]); + + Assert.Equal(BucketStatus.Unresolved, result.Total.Status); + Assert.Equal([Self, A], result.Total.DependencyPath!); + + // Covering the whole quarter, m2 no longer cuts it: the total resolves. + var wholeQuarter = Source(B, Daily(new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 1), 1)); + Assert.Equal(300 - 90, Evaluate("m1 - m2", Months(2025, 1, 3), [quarter, wholeQuarter]).Total.Value); + } + + [Fact] + public void A_ratio_is_non_additive_and_its_total_is_the_ratio_of_the_totals() + { + var a = Source(A, Monthly((2025, 1, 100), (2025, 2, 80))); + var b = Source(B, Monthly((2025, 1, 50), (2025, 2, 20))); + + var result = Evaluate("m1 / m2", a, b); + + Assert.Equal([2d, 4d], result.Values.Select(v => v.Value!.Value)); + Assert.False(result.IsAdditive); + Assert.Equal(180d / 70d, result.Total.Value!.Value, 12); // not 2 + 4 + } + + [Fact] + public void An_indicator_is_never_additive_even_with_a_linear_formula() + { + var result = Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], kind: QuantityKind.Indicator); + + Assert.False(result.IsAdditive); + Assert.False(VirtualSource.FromEvaluation(result).IsAdditive); + Assert.True(Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], kind: QuantityKind.Net).IsAdditive); + } + + [Fact] + public void Division_by_zero_makes_the_bucket_invalid_with_the_reason_never_zero_or_infinity() + { + var bZeroInFebruary = Source(B, Monthly((2025, 1, 50), (2025, 2, 0))); + + var result = Evaluate("m1 / m2", SourceA(), bZeroInFebruary); + + Assert.Equal(2, result.Values[0].Value); + var february = result.Values[1]; + Assert.Equal(BucketStatus.Invalid, february.Status); + Assert.Equal(ValueIssue.NonFinite, february.Issue); + Assert.Null(february.Value); + Assert.Equal([80d, 0d], result.Contributions.Select(c => c.UsedAmounts[1]!.Value)); + Assert.Equal(180d / 50d, result.Total.Value!.Value, 12); + } + + [Fact] + public void A_meter_outside_its_lifetime_contributes_a_known_zero() + { + // A retired at the end of June, its successor C installed on 1 July — no swap event joins them. + var retired = Source(A, Monthly([.. Enumerable.Range(1, 6).Select(m => (2024, m, 10d * m))])) with { RetiredAt = new DateOnly(2024, 6, 30) }; + var successor = Source(3, Monthly([.. Enumerable.Range(7, 6).Select(m => (2024, m, 100d + m))])) with { InstalledAt = new DateOnly(2024, 7, 1) }; + + var result = Evaluate("m1 + m3", Months(2024, 1, 12), [retired, successor]); + + Assert.All(result.Values, v => Assert.Equal(BucketStatus.Available, v.Status)); + Assert.Equal(10, result.Values[0].Value); + Assert.Equal(107, result.Values[6].Value); + Assert.Equal(210 + 657, result.Total.Value); + Assert.Equal(BucketStatus.Available, result.Total.Status); + } + + [Fact] + public void A_gap_inside_a_meters_lifetime_is_still_missing() + { + var withGap = Source(A, Monthly((2025, 1, 100))) with { InstalledAt = new DateOnly(2020, 1, 1) }; + + var result = Evaluate("m1 + m2", withGap, SourceB()); + + Assert.Equal(BucketStatus.Missing, result.Values[1].Status); + Assert.Equal([Self, A], result.Values[1].DependencyPath!); + } + + [Fact] + public void A_source_that_resolves_only_months_leaves_day_buckets_unresolved() + { + var days = Days(new DateOnly(2025, 1, 1), 31); + var monthlyA = Source(A, Monthly((2025, 1, 100))) with + { + BucketStates = [.. days.Select(_ => Unresolved())], + PeriodState = BucketValue.Available(100, Provenance.Imported), + }; + var dailyB = Source(B, Daily(new DateOnly(2025, 1, 1), new DateOnly(2025, 2, 1), 5)); + + var result = Evaluate("m1 + m2", days, [monthlyA, dailyB]); + + Assert.All(result.Values, v => + { + Assert.Equal(BucketStatus.Unresolved, v.Status); + Assert.Null(v.Value); + Assert.Equal(ValueIssue.CoarseResolution, v.Issue); + Assert.Equal([Self, A], v.DependencyPath!); + }); + + // The month as a whole is resolved: the period total is the full 100 + 31 × 5. + Assert.Equal(BucketValue.Available(255, Provenance.Derived | Provenance.Imported | Provenance.Measured), result.Total); + } + + [Fact] + public void A_source_reporting_unresolved_buckets_must_also_state_its_period() + { + // Whether the range as a whole is resolved does not follow from its buckets: every day of January is unresolved + // for a monthly source, yet January resolves (the test above). Guessing either way would be wrong somewhere. + var days = Days(new DateOnly(2025, 1, 1), 31); + var monthlyA = Source(A, Monthly((2025, 1, 100))) with { BucketStates = [.. days.Select(_ => Unresolved())] }; + + Assert.Throws(() => Evaluate("m1", days, [monthlyA])); + } + + [Fact] + public void Without_a_period_state_only_a_pending_or_invalid_bucket_decides_the_total() + { + var partialEdges = SourceA() with + { + BucketStates = [new BucketValue(100, BucketStatus.Partial, Provenance.Imported, ValueIssue.PartialCoverage), BucketValue.Available(80, Provenance.Imported)], + }; + Assert.Equal(BucketStatus.Available, Evaluate("m1 + m2", partialEdges, SourceB()).Total.Status); + + var pendingFebruary = SourceA() with + { + BucketStates = [BucketValue.Available(100, Provenance.Imported), new BucketValue(null, BucketStatus.Pending, Provenance.None, ValueIssue.AnalysisPending)], + }; + var total = Evaluate("m1 + m2", pendingFebruary, SourceB()).Total; + Assert.Equal(BucketStatus.Pending, total.Status); + Assert.Equal([Self, A], total.DependencyPath!); + } + + [Fact] + public void A_source_whose_bucket_state_is_partial_keeps_the_result_partial_even_when_its_days_are_covered() + { + // A's coverage ends at 10:00 while now is 15:00: the day holds A's row, but A itself says the day is partial. + var day = new DateOnly(2026, 3, 10); + var partial = new BucketValue(3, BucketStatus.Partial, Provenance.Measured, ValueIssue.SampleGap, "10:00"); + var a = Source(A, Daily(day, day.AddDays(1), 3)) with { BucketStates = [partial], PeriodState = partial }; + var b = Source(B, Daily(day, day.AddDays(1), 1)); + + var result = Evaluate("m1 + m2", [Day(day)], [a, b]); + + foreach (var value in new[] { result.Values[0], result.Total }) + { + Assert.Equal(BucketStatus.Partial, value.Status); + Assert.Equal(4, value.Value); + Assert.Equal(ValueIssue.SampleGap, value.Issue); + Assert.Equal("10:00", value.IssueDetail); + Assert.Equal([Self, A], value.DependencyPath!); + } + + Assert.Equal(BucketStatus.Partial, result.Contributions.Single(c => c.MeterId == A).Values[0].Status); + } + + [Fact] + public void Bucket_states_that_do_not_line_up_with_the_buckets_are_refused() + { + var oneState = SourceA() with { BucketStates = [BucketValue.Available(100, Provenance.Imported)] }; + + Assert.Throws(() => Evaluate("m1 + m2", oneState, SourceB())); + } + + [Fact] + public void A_nested_meter_evaluated_over_other_buckets_is_refused() + { + // Evaluated by month, the nested January value (36) would otherwise land on 1 January of a day series. + var nested = Evaluate("m1 + 5", JanFeb, [SourceA()], meterId: 10); + var days = Days(new DateOnly(2025, 1, 1), 2); + + Assert.Throws(() => Evaluate("m10 + m2", days, [VirtualSource.FromEvaluation(nested), Source(B, Daily(days[0].FirstDay, days[^1].EndDay, 1))])); + } + + [Fact] + public void A_source_still_being_built_makes_every_bucket_pending() + { + var pending = VirtualSource.Failed(B, BucketStatus.Pending, ValueIssue.AnalysisPending); + + var result = Evaluate("m1 + m2", SourceA(), pending); + + Assert.All(result.Values.Append(result.Total), v => + { + Assert.Equal(BucketStatus.Pending, v.Status); + Assert.Equal(ValueIssue.AnalysisPending, v.Issue); + Assert.Equal([Self, B], v.DependencyPath!); + }); + } + + [Fact] + public void A_referenced_meter_without_any_series_is_a_missing_source() + { + var result = Evaluate("m1 + m2", SourceA()); + + Assert.All(result.Values, v => + { + Assert.Equal((BucketStatus.Missing, ValueIssue.MissingSource), (v.Status, v.Issue)); + Assert.Equal([Self, B], v.DependencyPath!); + }); + } + + [Fact] + public void No_coverage_at_all_is_missing_without_a_culprit() + { + var result = Evaluate("m1 + m2", Source(A, []), Source(B, [])); + + Assert.All(result.Values, v => Assert.Equal(BucketValue.Missing(), v)); + Assert.Equal(BucketValue.Missing(), result.Total); + Assert.Null(result.FirstJointDay); + } + + [Fact] + public void A_formula_without_meters_is_an_invalid_definition() + { + var result = Evaluate("5"); + + Assert.All(result.Values.Append(result.Total), v => Assert.Equal((BucketStatus.Invalid, ValueIssue.InvalidDefinition), (v.Status, v.Issue))); + } + + [Fact] + public void Nested_virtual_meters_resolve_through_their_evaluation_and_report_the_path_to_a_missing_leaf() + { + const int sum = 10; + var inner = Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], meterId: sum); + var c = Source(3, Monthly((2025, 1, 50), (2025, 2, 50))); + + var outer = Evaluate("m10 - m3", JanFeb, [VirtualSource.FromEvaluation(inner), c]); + + Assert.Equal([200d, 150d], outer.Values.Select(v => v.Value!.Value)); + Assert.Equal(350, outer.Total.Value); + Assert.Equal([250d, 200d], outer.Contributions.Single(x => x.MeterId == sum).Values.Select(v => v.Value!.Value)); + + var innerWithGap = Evaluate("m1 + m2", JanFeb, [SourceA(), Source(B, Monthly((2025, 1, 150)))], meterId: sum); + var outerWithGap = Evaluate("m10 - m3", JanFeb, [VirtualSource.FromEvaluation(innerWithGap), c]); + var february = outerWithGap.Values[1]; + Assert.Equal((BucketStatus.Missing, ValueIssue.MissingSource), (february.Status, february.Issue)); + Assert.Equal([Self, sum, B], february.DependencyPath!); + } + + [Fact] + public void A_nested_ratio_enters_with_its_own_bucket_values_and_its_division_by_zero_surfaces_as_invalid() + { + // Monthly data books each month on its last day, so the ratio's days are mostly 0 / 0: summing them would be + // meaningless. A non-additive source therefore enters with its bucket value (2 in January), and makes the + // outer series non-additive as well. + var ratio = Evaluate("m1 / m2", JanFeb, [SourceA(), Source(B, Monthly((2025, 1, 50), (2025, 2, 0)))], meterId: 10, kind: QuantityKind.Indicator); + + var outer = Evaluate("m10 * 2", JanFeb, [VirtualSource.FromEvaluation(ratio)], kind: QuantityKind.Indicator); + + Assert.Equal(4, outer.Values[0].Value); + Assert.Equal((BucketStatus.Invalid, ValueIssue.NonFinite), (outer.Values[1].Status, outer.Values[1].Issue)); + Assert.Equal([Self, 10], outer.Values[1].DependencyPath!); + Assert.Equal(2 * 180d / 50d, outer.Total.Value!.Value, 12); + Assert.False(outer.IsAdditive); + } + + [Fact] + public void A_nested_meter_on_a_loop_makes_the_outer_meter_invalid_with_the_loop_path() + { + var looped = VirtualSource.Failed(10, BucketStatus.Invalid, ValueIssue.DependencyCycle, [10, 12, 10]); + + var result = Evaluate("m10 + m1", looped, SourceA()); + + Assert.All(result.Values, v => + { + Assert.Equal((BucketStatus.Invalid, ValueIssue.DependencyCycle), (v.Status, v.Issue)); + Assert.Equal([Self, 10, 12, 10], v.DependencyPath!); + }); + } + + [Fact] + public void A_sources_issue_detail_is_data_and_never_read_as_a_path() + { + // A detail that happens to be all digits (a year, a meter named "2") stays detail; the path is ids only. + var days = Days(new DateOnly(2025, 1, 1), 2); + var coarse = new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution, "2"); + var a = Source(A, Monthly((2025, 1, 100))) with { BucketStates = [coarse, coarse], PeriodState = coarse }; + + var result = Evaluate("m1", days, [a]); + + Assert.Equal("2", result.Values[0].IssueDetail); + Assert.Equal([Self, A], result.Values[0].DependencyPath!); + } + + [Fact] + public void Per_day_results_cover_exactly_the_joint_days_so_a_parent_can_use_them() + { + var jan = new DateOnly(2025, 1, 1); + var a = Source(A, Daily(jan, jan.AddDays(10), 3)); + var b = Source(B, Daily(jan.AddDays(5), jan.AddDays(20), 1)); + + var result = Evaluate("m1 - m2", [Month(2025, 1)], [a, b]); + + Assert.Equal(5, result.Days.Count); + Assert.All(result.Days.Values, d => Assert.Equal(2, d.Amount)); + Assert.Equal(jan.AddDays(5), result.FirstJointDay); + Assert.Equal(jan.AddDays(9), result.LastJointDay); + Assert.Equal(10, result.Values[0].Value); + } + + [Fact] + public void Per_day_results_carry_whether_every_source_was_divided_at_months() + { + var divided = Evaluate("m1 + m2", SourceA(), SourceB()); + var undivided = Evaluate("m1 + m2", Source(A, Monthly(false, (2025, 1, 100), (2025, 2, 80))), SourceB()); + + Assert.All(divided.Days.Values, d => Assert.True(d.DividedAtMonths)); + Assert.All(undivided.Days.Values, d => Assert.False(d.DividedAtMonths)); + } + + [Fact] + public void An_uncovered_amount_such_as_an_opening_balance_never_enters_a_sum() + { + var jan = new DateOnly(2025, 1, 1); + var days = Daily(jan, jan.AddDays(31), 1); + days[jan] = new SourceDay(5000, false, ResolutionClass.Day, Provenance.OpeningBalance); + + var result = Evaluate("m1", [Month(2025, 1)], [Source(A, days)]); + + Assert.Equal(BucketStatus.Partial, result.Values[0].Status); + Assert.Equal(30, result.Values[0].Value); + } +} diff --git a/tests/Core.Tests/Analysis/VirtualFixtures.cs b/tests/Core.Tests/Analysis/VirtualFixtures.cs new file mode 100644 index 0000000..2585a10 --- /dev/null +++ b/tests/Core.Tests/Analysis/VirtualFixtures.cs @@ -0,0 +1,115 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Tests.Analysis; + +/// Builders for virtual-meter tests: UTC buckets and source day maps in the shapes the reader produces. +internal static class VirtualFixtures +{ + public static DateTimeOffset Utc(DateOnly day) => new(day.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + + public static AnalysisBucket Month(int year, int month) + { + var first = new DateOnly(year, month, 1); + var end = first.AddMonths(1); + return new AnalysisBucket(first, end, Utc(first), Utc(end), BucketSize.Month); + } + + public static AnalysisBucket[] Months(int year, int firstMonth, int count) => + [.. Enumerable.Range(0, count).Select(i => new DateOnly(year, firstMonth, 1).AddMonths(i)).Select(d => Month(d.Year, d.Month))]; + + public static AnalysisBucket Day(DateOnly day) => new(day, day.AddDays(1), Utc(day), Utc(day.AddDays(1)), BucketSize.Day); + + public static AnalysisBucket[] Days(DateOnly first, int count) => + [.. Enumerable.Range(0, count).Select(i => Day(first.AddDays(i)))]; + + /// + /// A monthly-resolution series, as an imported monthly table produces it: every day of each listed month is + /// covered by a month-long run, and the month's amount is booked on its last day. Each interval lies inside its + /// month, so the run is divided at months (A-02) unless says otherwise (a tank + /// read on the 15th). + /// + public static Dictionary Monthly(bool dividedAtMonths, params (int Year, int Month, double Amount)[] months) + { + var days = new Dictionary(); + foreach (var (year, month, amount) in months) + { + var first = new DateOnly(year, month, 1); + var last = first.AddMonths(1).AddDays(-1); + for (var day = first; day <= last; day = day.AddDays(1)) + { + days[day] = new SourceDay( + day == last ? amount : 0d, true, ResolutionClass.Month, day == last ? Provenance.Imported : Provenance.None, dividedAtMonths); + } + } + + return days; + } + + /// A monthly-resolution series divided at months, the shape of an imported monthly table. + public static Dictionary Monthly(params (int Year, int Month, double Amount)[] months) => Monthly(true, months); + + /// + /// One interval longer than a month over [from, to), as a quarterly reading books it: covered at + /// , the whole amount on the last day, never divided. + /// + public static Dictionary Coarse(DateOnly from, DateOnly to, double amount) + { + var days = new Dictionary(); + for (var day = from; day < to; day = day.AddDays(1)) + { + var last = day == to.AddDays(-1); + days[day] = new SourceDay(last ? amount : 0d, true, ResolutionClass.Coarse, last ? Provenance.Imported : Provenance.None); + } + + return days; + } + + /// A daily-resolution series with the same amount on every day of [from, to). + public static Dictionary Daily(DateOnly from, DateOnly to, double perDay, Provenance provenance = Provenance.Measured) + { + var days = new Dictionary(); + for (var day = from; day < to; day = day.AddDays(1)) + { + days[day] = new SourceDay(perDay, true, ResolutionClass.Day, provenance); + } + + return days; + } + + public static VirtualSource Source(int meterId, Dictionary days) => new(meterId, days); + + public static CatalogMeter Physical(int id, string name, MeterMode mode, QuantityKind kind, string unit, short energyType = 1) => + new(id, name, mode, kind, unit, energyType); + + public static CatalogMeter Virtual(int id, string name, QuantityKind kind, string unit, string? expression, short energyType = 1) => + new(id, name, MeterMode.Virtual, kind, unit, energyType, expression is null ? null : new VirtualDefinition(expression, kind, unit)); + + /// + /// The seeded electricity meters (ReferenceDataImporter): Haus 1, Netz 2, Auto 3, Solar 1 = 4, Solar 2 = 5 and the + /// legacy virtual Summe Solar 6, plus a water meter 7 of another energy type. + /// + public static List SeededElectricity() => + [ + Physical(1, "Zähler Haus", MeterMode.CumulativeCounter, QuantityKind.Consumption, "kWh"), + Physical(2, "Zähler Netz", MeterMode.CumulativeCounter, QuantityKind.Consumption, "kWh"), + Physical(3, "Zähler Auto", MeterMode.CumulativeCounter, QuantityKind.Consumption, "kWh"), + Physical(4, "Solar 1", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh"), + Physical(5, "Solar 2", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh"), + new CatalogMeter(6, "Summe Solar", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1), + Physical(7, "Wasser", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m³", energyType: 2), + ]; + + /// The seeded links: Solar 1 → Summe, Solar 2 → Summe, Netz → Haus, Summe → Haus, Haus → Auto. + public static List SeededLinks() => + [ + Link(4, 6), + Link(5, 6), + Link(2, 1), + Link(6, 1), + Link(1, 3), + ]; + + public static MeterLink Link(int from, int to) => new() { FromMeterId = from, ToMeterId = to }; +} diff --git a/tests/Core.Tests/Analysis/VirtualValidatorTests.cs b/tests/Core.Tests/Analysis/VirtualValidatorTests.cs new file mode 100644 index 0000000..977d620 --- /dev/null +++ b/tests/Core.Tests/Analysis/VirtualValidatorTests.cs @@ -0,0 +1,353 @@ +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; +using static MeterVault.Core.Tests.Analysis.VirtualFixtures; + +namespace MeterVault.Core.Tests.Analysis; + +/// +/// Validation on save and on read (D-26): syntax, references, loops through nested virtual meters with their path, +/// and meaning — like is only added to like, a product of meters is an indicator with its own unit, and the cost +/// rule fits the formula's shape. +/// +public sealed class VirtualValidatorTests +{ + private static readonly MeterCatalog Catalog = new( + [ + .. SeededElectricity(), + Physical(8, "Einspeisung", MeterMode.CumulativeCounter, QuantityKind.Export, "kWh"), + Physical(9, "Brenner", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3), + Physical(10, "Gartenwasser", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m3", energyType: 2), + Virtual(13, "Unkonvertiert", QuantityKind.Generation, "kWh", expression: null), + Virtual(14, "Kaputt", QuantityKind.Consumption, "kWh", expression: "m1 +"), + Physical(19, "Brenner 2", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3), + Virtual(20, "Autarkie", QuantityKind.Indicator, "%", expression: "m2 / m1"), + Virtual(21, "Eigenverbrauchsquote", QuantityKind.Indicator, "%", expression: "m4 / m1"), + ]); + + private static VirtualValidation Validate(string expression, QuantityKind? kind = null, string? unit = null, VirtualCostRule? costRule = null, int meterId = 100) => + VirtualValidator.Validate(new VirtualDefinition(expression, kind, unit, costRule), meterId, Catalog); + + private static VirtualProblem Single(VirtualValidation validation) => Assert.Single(validation.Problems); + + [Fact] + public void Summe_solar_as_a_sum_of_two_generation_meters_is_generation_in_kWh_and_not_costed() + { + // A-15: generation is never billed (D-34), so a generation sum has no source costs to add; its default rule is none. + var validation = Validate("m4 + m5", meterId: 6); + + Assert.True(validation.IsValid); + Assert.Equal(QuantityKind.Generation, validation.Kind); + Assert.Equal("kWh", validation.Unit); + Assert.Equal(VirtualCostRule.None, validation.CostRule); + } + + [Fact] + public void Source_costs_over_a_nested_difference_are_refused_for_saving_and_not_costed_when_read() + { + // Review R2 (A-15): m30 + m3 is a pure sum at its own level, but m30 = m1 - m2, so the sources' metered costs + // (m1 + m2 + m3) are not the costs of its quantity (m1 - m2 + m3). The quantity stays valid; the rule does not. + var catalog = new MeterCatalog( + [ + .. SeededElectricity(), + Virtual(30, "Haus ohne Netz", QuantityKind.Consumption, "kWh", expression: "m1 - m2"), + Virtual(31, "Haus und Auto", QuantityKind.Consumption, "kWh", expression: "m1 + m3"), + ]); + + var declared = VirtualValidator.Validate(new VirtualDefinition("m30 + m3", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts), 40, catalog); + Assert.True(declared.IsValid); + Assert.False(declared.IsSavable); + Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, declared.CostRuleProblem!.Kind); + Assert.Equal([30], declared.CostRuleProblem.MeterIds); + Assert.Equal(VirtualCostRule.None, declared.CostRule); + + var inferred = VirtualValidator.Validate(new VirtualDefinition("m30 + m3"), 40, catalog); + Assert.Equal(VirtualCostRule.None, inferred.CostRule); + Assert.Null(inferred.CostRuleProblem); + + // A sum over a nested pure sum is a pure sum all the way down. + var nestedSum = VirtualValidator.Validate(new VirtualDefinition("m31 + m2", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts), 40, catalog); + Assert.True(nestedSum.IsSavable); + Assert.Equal(VirtualCostRule.SourceCosts, nestedSum.CostRule); + Assert.Null(VirtualValidator.NestedNonSum(nestedSum.Formula!, 40, catalog)); + } + + [Fact] + public void Netz_einsparung_as_haus_minus_netz_is_consumption_and_may_be_priced_as_its_own_quantity() + { + var byDefault = Validate("m1 - m2"); + Assert.True(byDefault.IsValid); + Assert.Equal(QuantityKind.Consumption, byDefault.Kind); + Assert.Equal(VirtualCostRule.None, byDefault.CostRule); + + Assert.True(Validate("m1 - m2", costRule: VirtualCostRule.OwnQuantity).IsValid); + } + + [Fact] + public void A_syntax_error_is_reported_with_its_position() + { + var problem = Single(Validate("m1 +")); + + Assert.Equal(VirtualProblemKind.Syntax, problem.Kind); + Assert.Equal(new FormulaError(FormulaErrorKind.UnexpectedEnd, 4), problem.SyntaxError); + } + + [Fact] + public void Unknown_meters_are_named() + { + var problem = Single(Validate("m1 + m99")); + + Assert.Equal(VirtualProblemKind.UnknownMeter, problem.Kind); + Assert.Equal([99], problem.MeterIds); + } + + [Fact] + public void A_meter_may_not_refer_to_itself() + { + var problem = Single(Validate("m6 + m4", meterId: 6)); + + Assert.Equal(VirtualProblemKind.SelfReference, problem.Kind); + Assert.Equal([6], problem.MeterIds); + } + + [Fact] + public void A_loop_through_nested_virtual_meters_is_reported_with_its_path() + { + var catalog = new MeterCatalog( + [ + .. SeededElectricity(), + Virtual(20, "A", QuantityKind.Consumption, "kWh", "m21 + m1"), + Virtual(21, "B", QuantityKind.Consumption, "kWh", "m22 - m2"), + Virtual(22, "C", QuantityKind.Consumption, "kWh", "m1"), + ]); + + // Saving C as "m20" closes the loop C → A → B → C. + var closing = VirtualValidator.Validate(new VirtualDefinition("m20"), 22, catalog); + var problem = Assert.Single(closing.Problems); + Assert.Equal(VirtualProblemKind.DependencyCycle, problem.Kind); + Assert.Equal([22, 20, 21, 22], problem.MeterIds); + + // A meter that only reads the loop is caught too, with the way into it. + var looped = new MeterCatalog([.. catalog.Meters.Where(m => m.MeterId != 22), Virtual(22, "C", QuantityKind.Consumption, "kWh", "m20")]); + var reader = VirtualValidator.Validate(new VirtualDefinition("m21 + m3"), 23, looped); + Assert.Equal([23, 21, 22, 20, 21], Assert.Single(reader.Problems).MeterIds); + } + + [Fact] + public void Adding_different_units_is_refused_even_for_a_declared_net_result() + { + foreach (var kind in new QuantityKind?[] { null, QuantityKind.Net }) + { + var problem = Single(Validate("m1 + m7", kind)); + + Assert.Equal(VirtualProblemKind.UnitMismatch, problem.Kind); + Assert.Equal([1, 7], problem.MeterIds); + Assert.Equal(["kWh", "m³"], problem.Values); + } + } + + [Fact] + public void Superscript_and_plain_cubic_metres_are_the_same_unit_and_the_result_is_in_canonical_spelling() + { + var water = Validate("m10 + m7"); + + Assert.True(water.IsValid); + Assert.Equal("m³", water.Unit); + } + + [Fact] + public void Units_are_compared_through_the_shared_unit_table_ignoring_case() + { + var typed = Validate("m1 - m2", unit: "kwh"); + + Assert.True(typed.IsValid); + Assert.Equal("kWh", typed.Unit); + Assert.Equal("kWh", typed.EffectiveDefinition!.ResultUnit); + } + + [Theory] + [InlineData(QuantityKind.Runtime)] + [InlineData(QuantityKind.Export)] + [InlineData(QuantityKind.Cost)] + public void A_result_kind_is_consumption_generation_net_or_indicator_and_nothing_else(QuantityKind kind) + { + var problem = Single(Validate("m9 + m19", kind)); + + Assert.Equal(VirtualProblemKind.ResultKindUnsupported, problem.Kind); + } + + [Fact] + public void A_sum_of_runtime_meters_needs_a_declared_result_kind() + { + // Two burners' hours are not consumption or generation; without a declaration the reader would take them for + // consumption (D-20's fallback) and a runtime result is not one a virtual meter can have. + var undeclared = Single(Validate("m9 + m19")); + Assert.Equal(VirtualProblemKind.ResultKindRequired, undeclared.Kind); + Assert.Equal(["runtime"], undeclared.Values); + + var net = Validate("m9 + m19", QuantityKind.Net); + Assert.True(net.IsValid); + Assert.Equal("h", net.Unit); + } + + [Fact] + public void Indicators_are_never_added_up_into_anything_but_an_indicator() + { + var asNet = Single(Validate("m20 + m21", QuantityKind.Net)); + Assert.Equal(VirtualProblemKind.IndicatorSourceNeedsIndicator, asNet.Kind); + Assert.Equal([20, 21], asNet.MeterIds); + + Assert.Equal(VirtualProblemKind.IndicatorSourceNeedsIndicator, Single(Validate("0.5 * m20", QuantityKind.Net)).Kind); + Assert.Equal(VirtualProblemKind.ResultKindRequired, Single(Validate("m20 + m21")).Kind); + Assert.Equal(VirtualProblemKind.ResultKindMismatch, Single(Validate("m20", QuantityKind.Consumption)).Kind); + + var indicator = Validate("m20 + m21", QuantityKind.Indicator); + Assert.True(indicator.IsValid); + Assert.Equal(VirtualCostRule.None, indicator.CostRule); + } + + [Fact] + public void A_formula_over_an_indicator_is_never_costed() + { + Assert.Equal(VirtualProblemKind.CostRuleNotForIndicator, Single(Validate("m20 + m21", QuantityKind.Indicator, costRule: VirtualCostRule.SourceCosts)).Kind); + + var net = Validate("m20 + m21", QuantityKind.Net, costRule: VirtualCostRule.SourceCosts); + Assert.Equal( + [VirtualProblemKind.IndicatorSourceNeedsIndicator, VirtualProblemKind.CostRuleNotForIndicator], + net.Problems.Select(p => p.Kind)); + + // Undeclared, the cost rule over an indicator is never the pure-sum default. + Assert.Equal(VirtualCostRule.None, Validate("m20 + m21", QuantityKind.Net).CostRule); + } + + [Fact] + public void The_effective_definition_writes_out_the_inferred_kind_unit_and_cost_rule() + { + var summeSolar = Validate("m4 + m5", meterId: 6); + + Assert.Equal(new VirtualDefinition("m4 + m5", QuantityKind.Generation, "kWh", VirtualCostRule.None), summeSolar.EffectiveDefinition); + Assert.Equal(new VirtualDefinition("m7 + m10", QuantityKind.Consumption, "m³", VirtualCostRule.SourceCosts), Validate("m7 + m10").EffectiveDefinition); + Assert.Null(Validate("m1 - m4").EffectiveDefinition); + } + + [Fact] + public void Mixing_kinds_needs_a_declared_net_result() + { + var undeclared = Single(Validate("m1 - m4")); + Assert.Equal(VirtualProblemKind.ResultKindRequired, undeclared.Kind); + Assert.Equal([1, 4], undeclared.MeterIds); + Assert.Equal(["consumption", "generation"], undeclared.Values); + + var asConsumption = Single(Validate("m1 - m4", QuantityKind.Consumption)); + Assert.Equal(VirtualProblemKind.KindMismatch, asConsumption.Kind); + Assert.Equal([1, 4], asConsumption.MeterIds); + + var net = Validate("m2 - m8", QuantityKind.Net); + Assert.True(net.IsValid); + Assert.Equal(QuantityKind.Net, net.Kind); + Assert.Equal("kWh", net.Unit); + } + + [Fact] + public void A_product_or_quotient_of_meters_must_be_a_declared_indicator_with_its_own_unit() + { + Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("m1 * m2")).Kind); + Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("m7 / m1", QuantityKind.Consumption)).Kind); + Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("1 / m1")).Kind); + Assert.Equal(VirtualProblemKind.IndicatorNeedsUnit, Single(Validate("m7 / m1", QuantityKind.Indicator)).Kind); + + var ratio = Validate("m7 / m1", QuantityKind.Indicator, "m³/kWh"); + Assert.True(ratio.IsValid); + Assert.Equal("m³/kWh", ratio.Unit); + Assert.Equal(VirtualCostRule.None, ratio.CostRule); + } + + [Fact] + public void Indicators_are_never_costed() + { + var problem = Single(Validate("m7 / m1", QuantityKind.Indicator, "m³/kWh", VirtualCostRule.OwnQuantity)); + + Assert.Equal(VirtualProblemKind.CostRuleNotForIndicator, problem.Kind); + } + + [Fact] + public void Scaling_by_a_constant_keeps_the_quantity_and_its_unit() + { + var half = Validate("0.5 * m1 + m2 / 2", costRule: VirtualCostRule.OwnQuantity); + + Assert.True(half.IsValid); + Assert.Equal(QuantityKind.Consumption, half.Kind); + Assert.Equal("kWh", half.Unit); + } + + [Fact] + public void Source_costs_need_a_pure_sum_and_own_quantity_a_linear_formula() + { + Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, Single(Validate("m1 - m2", costRule: VirtualCostRule.SourceCosts)).Kind); + Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, Single(Validate("0.5 * m1", costRule: VirtualCostRule.SourceCosts)).Kind); + Assert.Equal(VirtualProblemKind.CostRuleNeedsLinear, Single(Validate("m1 + 5", costRule: VirtualCostRule.OwnQuantity)).Kind); + Assert.True(Validate("m1 + 5").IsValid); // allowed, but non-additive and not priceable + } + + [Fact] + public void A_declared_kind_or_unit_that_contradicts_the_sources_is_refused() + { + var kind = Single(Validate("m4 + m5", QuantityKind.Consumption)); + Assert.Equal(VirtualProblemKind.ResultKindMismatch, kind.Kind); + Assert.Equal(["consumption", "generation"], kind.Values); + + var unit = Single(Validate("m1 - m2", unit: "MWh")); + Assert.Equal(VirtualProblemKind.ResultUnitMismatch, unit.Kind); + Assert.Equal(["MWh", "kWh"], unit.Values); + + Assert.Equal(VirtualProblemKind.ResultKindUnsupported, Single(Validate("m1", QuantityKind.Cost)).Kind); + } + + [Fact] + public void A_formula_without_meters_is_not_a_meter() + { + Assert.Equal(VirtualProblemKind.NoReferences, Single(Validate("5")).Kind); + } + + [Fact] + public void Nested_virtual_sources_must_themselves_be_configured_and_parse() + { + Assert.Equal(VirtualProblemKind.SourceNotConfigured, Single(Validate("m13 + m4")).Kind); + Assert.Equal(VirtualProblemKind.SourceInvalid, Single(Validate("m14 + m1")).Kind); + } + + [Fact] + public void A_nested_virtual_source_counts_with_its_result_kind_and_unit() + { + var catalog = new MeterCatalog([.. SeededElectricity(), Virtual(20, "Summe Solar", QuantityKind.Generation, "kWh", "m4 + m5")]); + + var validation = VirtualValidator.Validate(new VirtualDefinition("m20 - m4"), 21, catalog); + + Assert.True(validation.IsValid); + Assert.Equal(QuantityKind.Generation, validation.Kind); + } + + [Theory] + [InlineData(new[] { QuantityKind.Generation, QuantityKind.Generation }, QuantityKind.Generation)] + [InlineData(new[] { QuantityKind.Consumption }, QuantityKind.Consumption)] + [InlineData(new[] { QuantityKind.Runtime, QuantityKind.Runtime }, null)] + [InlineData(new[] { QuantityKind.Export }, null)] + [InlineData(new[] { QuantityKind.Net }, null)] + [InlineData(new[] { QuantityKind.Consumption, QuantityKind.Generation }, null)] + [InlineData(new[] { QuantityKind.Indicator }, null)] + [InlineData(new QuantityKind[0], null)] + public void The_default_kind_is_consumption_or_generation_when_every_source_shares_it_and_none_otherwise(QuantityKind[] kinds, QuantityKind? expected) + { + Assert.Equal(expected, VirtualValidator.DefaultKind(kinds)); + } + + [Theory] + [InlineData("m4 + m5", null, VirtualCostRule.SourceCosts)] + [InlineData("m4 + m5", QuantityKind.Indicator, VirtualCostRule.None)] + [InlineData("m1 - m2", null, VirtualCostRule.None)] + [InlineData("m1 / m2", QuantityKind.Indicator, VirtualCostRule.None)] + public void The_default_cost_rule_is_source_costs_for_pure_sums_and_none_otherwise(string expression, QuantityKind? kind, VirtualCostRule expected) + { + Assert.Equal(expected, VirtualValidator.DefaultCostRule(Formula.Parse(expression), kind)); + } +} diff --git a/tests/Core.Tests/ExpressionEvaluatorTests.cs b/tests/Core.Tests/ExpressionEvaluatorTests.cs deleted file mode 100644 index 4213e32..0000000 --- a/tests/Core.Tests/ExpressionEvaluatorTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using MeterVault.Core.Normalization.Expressions; - -namespace MeterVault.Core.Tests; - -public sealed class ExpressionEvaluatorTests -{ - private static readonly Dictionary Vars = new() - { - ["m1"] = 411, - ["m2"] = 416, - }; - - [Theory] - [InlineData("1 + 2 * 3", 7)] - [InlineData("(1 + 2) * 3", 9)] - [InlineData("-5", -5)] - [InlineData("10 / 4", 2.5)] - [InlineData("2 - 3 - 4", -5)] // left-associative - [InlineData("m1 - m2", -5)] - [InlineData("m1 + m2", 827)] - [InlineData("unknown + 1", 1)] // unknown identifiers resolve to 0 - public void Evaluates_arithmetic(string expression, double expected) - { - var result = ExpressionEvaluator.Compile(expression).Evaluate(Vars); - - Assert.Equal(expected, result, 6); - } - - [Theory] - [InlineData("1 +")] - [InlineData("(1 + 2")] - [InlineData("1 2")] - [InlineData("")] - public void Rejects_malformed_expressions(string expression) => - Assert.ThrowsAny(() => ExpressionEvaluator.Compile(expression)); -} diff --git a/tests/Core.Tests/VirtualMeterTests.cs b/tests/Core.Tests/VirtualMeterTests.cs index 9bece14..bbf1f57 100644 --- a/tests/Core.Tests/VirtualMeterTests.cs +++ b/tests/Core.Tests/VirtualMeterTests.cs @@ -1,75 +1,50 @@ -using MeterVault.Core.Domain; -using MeterVault.Core.Normalization; -using static MeterVault.Core.Tests.TestData; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Virtual; +using static MeterVault.Core.Tests.Analysis.VirtualFixtures; namespace MeterVault.Core.Tests; /// /// The electricity derived columns are data-driven virtual expressions, not hardcoded formulas -/// (SDD §2.2, §7.4): Netz Einsparung = Haus − Netz, Anlage Eigenverbrauch = Erzeugung − Einsparung. +/// (SDD §2.2, §7.4): Netz Einsparung = Haus − Netz, Anlage Eigenverbrauch = Erzeugung − Einsparung. Ported from +/// the removed VirtualNormalizer to the evaluator that runs on read (D-29), over month buckets. /// public sealed class VirtualMeterTests { - private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); - - private static Consumption Cons(int meterId, DateTimeOffset time, double amount) => new() - { - MeterId = meterId, - Time = time, - Amount = amount, - Kind = ConsumptionKind.Consumption, - Quality = ReadingQuality.Imported, - }; + private const int Haus = 1; + private const int Netz = 2; + private const int Solar = 50; + private const int NetzEinsparung = 100; + private const int Eigenverbrauch = 101; [Fact] public void Netz_einsparung_is_haus_minus_netz() { - var oct = Month(2022, 10); - var nov = Month(2022, 11); - var ctx = new NormalizationContext - { - Meter = new MeterConfig - { - MeterId = 100, - Mode = MeterMode.Virtual, - Unit = "kWh", - Virtual = new VirtualSpec { Expression = "m1 - m2", ReferencedMeterIds = [1, 2] }, - }, - ReferencedSeries = new Dictionary> - { - [1] = [Cons(1, oct, 411), Cons(1, nov, 742)], // Haus Verbrauch - [2] = [Cons(2, oct, 416), Cons(2, nov, 832)], // Netz Verbrauch - }, - }; + var haus = Source(Haus, Monthly((2022, 10, 411), (2022, 11, 742))); // Haus Verbrauch + var netz = Source(Netz, Monthly((2022, 10, 416), (2022, 11, 832))); // Netz Verbrauch - var result = _engine.Normalize(ctx); + var result = VirtualEvaluator.Evaluate( + NetzEinsparung, Formula.Parse("m1 - m2"), QuantityKind.Consumption, Months(2022, 10, 2), [haus, netz]); - Assert.Equal([-5d, -90d], result.Select(c => c.Amount)); + Assert.Equal([-5d, -90d], result.Values.Select(v => v.Value!.Value)); } [Fact] public void Eigenverbrauch_is_erzeugung_minus_einsparung() { - var oct = Month(2022, 10); - var ctx = new NormalizationContext - { - Meter = new MeterConfig - { - MeterId = 101, - Mode = MeterMode.Virtual, - Unit = "kWh", - Virtual = new VirtualSpec { Expression = "m50 - m100", ReferencedMeterIds = [50, 100] }, - }, - ReferencedSeries = new Dictionary> - { - [50] = [Cons(50, oct, 76)], // Solar Erzeugung - [100] = [Cons(100, oct, -5)], // Netz Einsparung (from the previous test) - }, - }; + var october = Months(2022, 10, 1); + var einsparung = VirtualEvaluator.Evaluate( + NetzEinsparung, + Formula.Parse("m1 - m2"), + QuantityKind.Consumption, + october, + [Source(Haus, Monthly((2022, 10, 411))), Source(Netz, Monthly((2022, 10, 416)))]); + var solar = Source(Solar, Monthly((2022, 10, 76))); // Solar Erzeugung - var result = _engine.Normalize(ctx); + var result = VirtualEvaluator.Evaluate( + Eigenverbrauch, Formula.Parse("m50 - m100"), QuantityKind.Net, october, [solar, VirtualSource.FromEvaluation(einsparung)]); - // 76 − (−5) = 81 (Anlage Eigenverbrauch Okt 2022). - Assert.Equal([81d], result.Select(c => c.Amount)); + // 76 − (−5) = 81 (Anlage Eigenverbrauch Okt 2022), through the nested Netz Einsparung meter. + Assert.Equal([81d], result.Values.Select(v => v.Value!.Value)); } } diff --git a/tests/Integration.Tests/Analysis/AnalysisCatalogTests.cs b/tests/Integration.Tests/Analysis/AnalysisCatalogTests.cs new file mode 100644 index 0000000..d2a7f20 --- /dev/null +++ b/tests/Integration.Tests/Analysis/AnalysisCatalogTests.cs @@ -0,0 +1,32 @@ +using MeterVault.Core.Analysis.Virtual; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Analysis; + +namespace MeterVault.Integration.Tests.Analysis; + +/// +/// The analysis catalog built from loaded rows, without a database: one meter's broken configuration stays that meter's +/// finding and never fails the catalog every analysis, cost, flow and solar read is built on. +/// +public sealed class AnalysisCatalogTests +{ + [Fact] + public void A_legacy_sum_of_hundreds_of_links_needs_configuration_and_the_other_meters_still_read() + { + // Review virtual F3: an expression-less virtual meter fed by 300 links implies a formula longer than 2,000 + // characters. Deriving it used to throw out of Build, which every read calls. + var meters = Enumerable.Range(1000, 300) + .Select(id => new Meter { Id = id, Name = $"PV {id}", EnergyTypeId = 1, Mode = MeterMode.GenerationCounter, Unit = "kWh", Meta = "{}" }) + .Append(new Meter { Id = 5000, Name = "Sum", EnergyTypeId = 1, Mode = MeterMode.Virtual, Unit = "kWh", Meta = "{}" }) + .Append(new Meter { Id = 6000, Name = "Unrelated", EnergyTypeId = 2, Mode = MeterMode.CumulativeCounter, Unit = "kWh", Meta = "{}" }) + .ToList(); + var links = Enumerable.Range(1000, 300).Select(id => new MeterLink { FromMeterId = id, ToMeterId = 5000 }).ToList(); + + var catalog = AnalysisCatalog.Build(meters, [], links, [], TimeZoneInfo.Utc); + + Assert.Equal(VirtualMeterStatus.NeedsConfiguration, catalog.Find(5000)!.VirtualStatus); + Assert.Equal(LegacyDerivationOutcome.Invalid, catalog.Find(5000)!.Legacy!.Outcome); + Assert.NotNull(catalog.Find(6000)); + Assert.NotNull(catalog.Find(1000)); + } +} diff --git a/tests/Integration.Tests/Analysis/AnalysisChartModelTests.cs b/tests/Integration.Tests/Analysis/AnalysisChartModelTests.cs new file mode 100644 index 0000000..1a6b26f --- /dev/null +++ b/tests/Integration.Tests/Analysis/AnalysisChartModelTests.cs @@ -0,0 +1,318 @@ +using System.Globalization; +using ApexCharts; +using MeterVault.App.Analysis; +using MeterVault.Core.Analysis; +using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData; + +namespace MeterVault.Integration.Tests.Analysis; + +/// +/// The analysis chart's plan and options (D-49, brief §8), without the chart library's browser half: unknown buckets +/// stay gaps and a true zero stays a point, qualified buckets are marked in words and shape, overlays pair with their +/// buckets by index (A-10), labels carry the year across years, units never share an axis, the baseline is a real zero, +/// and the theme reaches the options. Pure; no database. +/// +public sealed class AnalysisChartModelTests +{ + private static readonly ChartPalette Dark = ChartPalette.For(isDark: true); + + [Fact] + public void Unknown_buckets_stay_gaps_and_a_true_zero_stays_a_point() => In("en", () => + { + var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31)); + var series = new AnalysisChartSeries("m1", "Haus", "kWh", [Available(100), Missing(), Available(0)]); + + var plan = AnalysisChartPlan.Build(buckets, [series], Dark); + + var points = plan.Panels.Single().Series.Single().Points; + Assert.Equal([100m, null, 0m], points.Select(p => p.Value)); + Assert.Equal([false, true, false], points.Select(p => p.IsQualified)); + Assert.Equal(["Jan", "Feb" + AnalysisChartPlan.GapMarker, "Mar"], plan.Panels[0].Labels); + Assert.Equal(["Jan", "Feb", "Mar"], plan.Labels); + Assert.Equal("—", points[1].Tooltip.Split(" · ")[0]); + Assert.Contains("No data", points[1].Tooltip, StringComparison.Ordinal); + Assert.Equal("0 kWh", points[2].Tooltip); + Assert.True(plan.HasValues); + }); + + [Fact] + public void A_series_shorter_than_the_plan_is_unknown_at_the_end_never_zero() + { + var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31)); + + var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Haus", "kWh", [Available(5)])], Dark); + + Assert.Equal([5m, null, null], plan.Panels[0].Series[0].Points.Select(p => p.Value)); + Assert.Equal([false, true, true], plan.Marked); + } + + [Fact] + public void A_true_zero_bar_is_drawn_on_the_baseline_and_a_bucket_without_value_is_marked_as_such() => In("en", () => + { + // Brief §4.3 / DoD: a valid zero is an actual chart point, and it must not look like a month without data. A bar + // series draws an outline, so a zero is a line on the baseline; a gap has no bar and its own mark and note. + var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31)); + var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Auto", "kWh", [Available(0), Missing(), Available(0)])], Dark); + + var panel = plan.Panels.Single(); + Assert.Equal(["Jan", "Feb" + AnalysisChartPlan.GapMarker, "Mar"], panel.Labels); + Assert.False(panel.HasMarked); + Assert.True(panel.HasGaps); + Assert.True(plan.HasValues); + Assert.Equal([0m, null, 0m], panel.Series[0].Points.Select(p => p.Value)); + Assert.True(panel.Series[0].StrokeWidth >= 1); + + // A partial month keeps the "*" of a qualified value; both marks can meet in one bucket of two series. + var both = AnalysisChartPlan.Build( + buckets, + [ + new AnalysisChartSeries("m1", "Auto", "kWh", [Available(1), Partial(2), Available(3)]), + new AnalysisChartSeries("m2", "Haus", "kWh", [Available(1), Missing(), Available(3)]), + ], + Dark); + Assert.Equal(["Jan", "Feb" + AnalysisChartPlan.Marker + AnalysisChartPlan.GapMarker, "Mar"], both.Panels[0].Labels); + Assert.True(both.Panels[0].HasMarked); + Assert.True(both.Panels[0].HasGaps); + }); + + [Fact] + public void A_plan_without_values_says_why_coarser_data_or_no_price_rather_than_no_data() => In("en", () => + { + var buckets = Buckets(D(2026, 5, 1), D(2026, 5, 3), BucketSize.Day); + var unresolved = new BucketValue(null, BucketStatus.Unresolved, Provenance.Measured, ValueIssue.CoarseResolution); + + var coarse = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Wasser", "m³", [unresolved, unresolved, unresolved])], Dark); + Assert.False(coarse.HasValues); + Assert.Equal(ChartEmptyReason.Unresolved, coarse.EmptyReason); + + var nothing = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Wasser", "m³", [Missing(), Missing(), Missing()])], Dark); + Assert.Equal(ChartEmptyReason.NoData, nothing.EmptyReason); + + // Valid quantities without any tariff: the cost is unavailable, and the chart says so in the cost card's words. + var months = Buckets(D(2026, 1, 1), D(2026, 3, 31)); + var none = Priced(months, 100, null).Lines[0].Buckets; + var unpriced = AnalysisChartPlan.Build(months, [AnalysisChartSeries.ForCost("c", "Heizöl", "EUR", none)], Dark); + Assert.False(unpriced.HasValues); + Assert.Equal(ChartEmptyReason.NotPriced, unpriced.EmptyReason); + Assert.Equal("Not priced (no tariff)", unpriced.EmptyStatus); + + Assert.Equal(ChartEmptyReason.None, AnalysisChartPlan.Build(months, [new AnalysisChartSeries("m1", "Haus", "kWh", [Available(0), Missing(), Missing()])], Dark).EmptyReason); + }); + + [Fact] + public void Nothing_known_draws_nothing() + { + var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28)); + + var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Haus", "kWh", [Missing(), Missing()])], Dark); + + Assert.False(plan.HasValues); + } + + [Fact] + public void Partial_and_estimated_buckets_are_marked_in_words_and_faded_not_by_colour_alone() => In("en", () => + { + var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31)); + var values = new[] { Available(10), Partial(4), Available(8, Provenance.Measured | Provenance.Estimated) }; + + var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Haus", "kWh", values)], Dark); + + var series = plan.Panels[0].Series[0]; + var colour = Dark.SeriesColor(0); + Assert.Equal(colour, series.Color); + Assert.Equal(colour, series.Points[0].FillColor); + Assert.Equal(ChartPalette.WithAlpha(colour, AnalysisChartPlan.QualifiedAlpha), series.Points[1].FillColor); + Assert.StartsWith("rgba(", series.Points[2].FillColor, StringComparison.Ordinal); + + // The words travel with the point, and the label says "look here" without colour. + Assert.Equal("4.0 kWh · Partial · Measured — Data covers only part of this period", series.Points[1].Tooltip); + Assert.Contains("Estimated", series.Points[2].Tooltip, StringComparison.Ordinal); + Assert.Equal(["Jan", "Feb *", "Mar *"], plan.Panels[0].Labels); + Assert.True(plan.Panels[0].HasMarked); + }); + + [Fact] + public void A_comparison_overlay_pairs_with_its_buckets_by_index_and_names_its_own() => In("en", () => + { + var period = Range(D(2026, 1, 1), D(2026, 3, 31)); + var buckets = BucketPlanner.Plan(period, BucketSize.Month).Buckets; + var resolution = ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear)); + var pairs = ComparisonResolver.PairBuckets(period, resolution.Period!, buckets); + var reader = Series( + 1, "Haus", [Available(120), Available(100), Available(90)], + comparison: Comparison([Available(100), Missing(), Available(90)], Available(190), Change.Unavailable)); + + var current = AnalysisChartSeries.ForSeries(reader); + var overlay = AnalysisChartSeries.ComparisonOf(reader, AnalysisChartSeries.ComparisonName("Haus", new ComparisonRequest(ComparisonKind.PreviousYear)))!; + var plan = AnalysisChartPlan.Build(buckets, [current, overlay], Dark, pairs); + + var drawn = plan.Panels.Single().Series; + Assert.Equal(2, drawn.Count); + var line = drawn[1]; + Assert.True(line.IsComparison); + Assert.Equal(ChartSeriesStyle.Line, line.Style); + Assert.Equal(drawn[0].Color, line.Color); + Assert.Equal(5, line.DashSpace); + Assert.Equal("Haus (Same period last year)", line.Name); + + // Point i of the overlay is the image of bucket i: January 2025 beside January 2026, a gap where 2025 had none. + Assert.Equal([100m, null, 90m], line.Points.Select(p => p.Value)); + Assert.Equal("Jan 2025: 100 kWh", line.Points[0].Tooltip); + Assert.StartsWith("Feb 2025: —", line.Points[1].Tooltip, StringComparison.Ordinal); + + // An overlay's gaps do not mark the current buckets. + Assert.Equal(["Jan", "Feb", "Mar"], plan.Panels[0].Labels); + Assert.False(plan.Panels[0].HasMarked); + }); + + [Fact] + public void Labels_carry_the_year_across_years_and_stay_distinct() => In("en", () => + { + Assert.Equal(["Nov 2025", "Dec 2025", "Jan 2026", "Feb 2026"], AnalysisChartPlan.BucketLabels(Buckets(D(2025, 11, 1), D(2026, 2, 28)))); + Assert.Equal(["Oct", "Nov", "Dec"], AnalysisChartPlan.BucketLabels(Buckets(D(2025, 10, 1), D(2025, 12, 31)))); + Assert.Equal(["2024", "2025"], AnalysisChartPlan.BucketLabels(Buckets(D(2024, 1, 1), D(2025, 12, 31), BucketSize.Year))); + + var days = AnalysisChartPlan.BucketLabels(Buckets(D(2025, 12, 30), D(2026, 1, 2), BucketSize.Day)); + Assert.Equal(["Dec 30, 2025", "Dec 31, 2025", "Jan 1, 2026", "Jan 2, 2026"], days); + }); + + [Fact] + public void Series_of_different_units_never_share_an_axis() + { + var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28)); + var costs = Priced(buckets, 100, 0.3).Lines[0].Buckets; + + var plan = AnalysisChartPlan.Build( + buckets, + [ + new AnalysisChartSeries("m1", "Haus", "kWh", [Available(1), Available(2)]), + new AnalysisChartSeries("m2", "Wasser", "m³", [Available(3), Available(4)]), + new AnalysisChartSeries("m3", "Auto", "kWh", [Available(5), Available(6)]), + AnalysisChartSeries.ForCost("cost", "Cost", "EUR", costs), + ], + Dark); + + Assert.Equal(["kWh", "m³", "€"], plan.Panels.Select(p => p.Unit)); + Assert.Equal(["Haus", "Auto"], plan.Panels[0].Series.Select(s => s.Name)); + + // Colour follows the series in the order given, across panels. + Assert.Equal([Dark.SeriesColor(0), Dark.SeriesColor(2)], plan.Panels[0].Series.Select(s => s.Color)); + Assert.Equal(Dark.SeriesColor(1), plan.Panels[1].Series[0].Color); + } + + [Fact] + public void Two_meters_with_one_name_stay_two_series() + { + var buckets = Buckets(D(2026, 1, 1), D(2026, 1, 31)); + + var plan = AnalysisChartPlan.Build( + buckets, + [new AnalysisChartSeries("m1", "Keller", "kWh", [Available(1)]), new AnalysisChartSeries("m2", "Keller", "kWh", [Available(2)])], + Dark); + + Assert.Equal(["Keller", "Keller (2)"], plan.Panels[0].Series.Select(s => s.Name)); + } + + [Fact] + public void Costs_without_a_price_are_gaps_with_the_reason() => In("en", () => + { + var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31)); + var gap = Priced(buckets, 100, 0.25, priceFrom: D(2026, 2, 1)).Lines[0].Buckets; + var none = Priced(buckets, 100, null).Lines[0].Buckets; + + var plan = AnalysisChartPlan.Build( + buckets, + [AnalysisChartSeries.ForCost("a", "Strom", "EUR", gap), AnalysisChartSeries.ForCost("b", "Wasser", "EUR", none)], + Dark); + + var withGap = plan.Panels[0].Series[0].Points; + Assert.Equal([null, 25m, 25m], withGap.Select(p => p.Value)); + Assert.Equal("— · Unavailable (tariff gap)", withGap[0].Tooltip); + Assert.Equal("25.00 €", withGap[1].Tooltip); + Assert.All(plan.Panels[0].Series[1].Points, p => Assert.Null(p.Value)); + Assert.Contains("Not priced (no tariff)", plan.Panels[0].Series[1].Points[0].Tooltip, StringComparison.Ordinal); + }); + + [Fact] + public void The_baseline_is_a_real_zero_and_signed_values_get_a_zero_line() + { + var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28)); + + var signed = Options(buckets, [Available(-50), Available(30)]); + Assert.Null(signed.Yaxis[0].Min); + Assert.Null(signed.Yaxis[0].Max); + Assert.Equal(0, Assert.Single(signed.Annotations.Yaxis).Y); + + var positive = Options(buckets, [Available(20), Available(30)]); + Assert.Equal(0, positive.Yaxis[0].Min); + Assert.Null(positive.Annotations); + + var negative = Options(buckets, [Available(-20), Available(-30)]); + Assert.Equal(0, negative.Yaxis[0].Max); + } + + [Fact] + public void Options_follow_the_theme_draw_straight_lines_and_do_not_animate() + { + var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31)); + var series = new AnalysisChartSeries("m1", "Haus", "kWh", [Available(1), Partial(2), Available(3)]) { Style = ChartSeriesStyle.Line }; + var light = ChartPalette.For(isDark: false); + + foreach (var palette in new[] { Dark, light }) + { + var plan = AnalysisChartPlan.Build(buckets, [series], palette); + var options = AnalysisChartOptions.Build(plan.Panels[0], palette, CultureInfo.GetCultureInfo("de-DE")); + + Assert.Equal("transparent", options.Chart.Background); + Assert.Equal(palette.IsDark ? Mode.Dark : Mode.Light, options.Theme.Mode); + Assert.Equal(palette.Text, options.Chart.ForeColor); + Assert.Equal(Curve.Straight, options.Stroke.Curve.Single()); + Assert.False(options.Chart.Animations.Enabled); + Assert.Contains("\"de-DE\"", options.Yaxis[0].Labels.Formatter, StringComparison.Ordinal); + Assert.Contains("\" kWh\"", options.Yaxis[0].Labels.Formatter, StringComparison.Ordinal); + Assert.Equal(ChartFormatters.Tooltip, options.Tooltip.Y.Formatter); + + // The partial point of the line is a hollow square: its shape marks it. + var marker = Assert.Single(options.Markers.Discrete); + Assert.Equal(1, marker.DataPointIndex); + Assert.Equal(MarkerShape.Square, marker.Shape); + } + + Assert.NotEqual(Dark.Series[2], light.Series[2]); + } + + [Fact] + public void Formatter_strings_cannot_be_broken_out_of() + { + var formatter = ChartFormatters.Axis("m\"3", CultureInfo.GetCultureInfo("en-US")); + + Assert.DoesNotContain("m\"3", formatter, StringComparison.Ordinal); + Assert.DoesNotContain("", formatter, StringComparison.Ordinal); + Assert.Contains("\"en-US\"", formatter, StringComparison.Ordinal); + Assert.Equal("\"\\u20AC\"", ChartFormatters.Literal("€")); + Assert.StartsWith("function (value, opts)", ChartFormatters.Tooltip, StringComparison.Ordinal); + Assert.Contains("extra.text", ChartFormatters.Tooltip, StringComparison.Ordinal); + } + + [Fact] + public void The_palette_comes_from_the_theme_as_plain_colours() + { + foreach (var palette in new[] { ChartPalette.For(true), ChartPalette.For(false) }) + { + Assert.Equal(6, palette.Series.Count); + Assert.All(palette.Series, c => Assert.Matches("^#[0-9A-F]{6}$", c)); + Assert.Equal(palette.Series[0], palette.SeriesColor(6)); + Assert.StartsWith("rgba(", palette.Text, StringComparison.Ordinal); + } + + Assert.Equal("#14B8A6", ChartPalette.For(true).Series[0]); + Assert.Equal("rgba(20,184,166,0.45)", ChartPalette.WithAlpha("#14B8A6", 0.45)); + Assert.Equal("rgba(1,2,3,0.5)", ChartPalette.WithAlpha("rgba(1,2,3,0.5)", 0.2)); + } + + private static ApexChartOptions Options(IReadOnlyList buckets, BucketValue[] values) + { + var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Netz", "kWh", values)], Dark); + return AnalysisChartOptions.Build(plan.Panels[0], Dark, CultureInfo.InvariantCulture); + } +} diff --git a/tests/Integration.Tests/Analysis/AnalysisComponentRenderTests.cs b/tests/Integration.Tests/Analysis/AnalysisComponentRenderTests.cs new file mode 100644 index 0000000..1d1c135 --- /dev/null +++ b/tests/Integration.Tests/Analysis/AnalysisComponentRenderTests.cs @@ -0,0 +1,348 @@ +using System.Net; +using MeterVault.App; +using MeterVault.App.Analysis; +using MeterVault.App.Components.Shared.Analysis; +using MeterVault.App.Theme; +using MeterVault.Core.Analysis; +using MeterVault.Core.Analysis.Coverage; +using MeterVault.Infrastructure.Analysis; +using MeterVault.Infrastructure.Options; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; +using MudBlazor.Services; +using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData; + +namespace MeterVault.Integration.Tests.Analysis; + +/// +/// The shared analysis components rendered to HTML with the framework's static (no bUnit, no +/// browser): they render with real MudBlazor services, the title is the page's h1, the toolbar shows the effective +/// dates, the table says "—" and speaks statuses, the empty and error states offer their actions, attention items link +/// to their fix. Static rendering proves the markup, not the interactive chart or navigation — those are covered by the +/// pure model tests and the manual checklist. +/// +public sealed class AnalysisComponentRenderTests +{ + [Fact] + public async Task The_page_header_is_the_pages_h1_with_breadcrumbs_carrying_the_period() + { + var ytd = AnalysisQuery.Default(AnalysisDefaults.History.ForScope(QueryScope.ForMeter(5))).WithPeriod(PeriodPreset.YearToDate); + RenderFragment crumbs = builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(AnalysisBreadcrumbs.Query), ytd); + builder.AddComponentParameter(2, nameof(AnalysisBreadcrumbs.EnergyTypeId), 1); + builder.AddComponentParameter(3, nameof(AnalysisBreadcrumbs.EnergyTypeName), "Strom"); + builder.AddComponentParameter(4, nameof(AnalysisBreadcrumbs.MeterId), 5); + builder.AddComponentParameter(5, nameof(AnalysisBreadcrumbs.MeterName), "Wärmepumpe "); + builder.CloseComponent(); + }; + + var raw = await RenderRawAsync("en", new() + { + [nameof(PageHeader.Title)] = "Wärmepumpe ", + [nameof(PageHeader.Description)] = "Heat pump", + [nameof(PageHeader.Breadcrumbs)] = crumbs, + }); + var html = WebUtility.HtmlDecode(raw); + + // User data is text, never markup; the title is the page's one h1. + Assert.Contains("<Keller>", raw, StringComparison.Ordinal); + Assert.Contains("

Wärmepumpe

", html, StringComparison.Ordinal); + Assert.Contains("href=\"/?period=ytd\"", html, StringComparison.Ordinal); + Assert.Contains("href=\"/energy/1?period=ytd\"", html, StringComparison.Ordinal); + Assert.Contains("