From f381f034d33e2919724ed8fea01344be455da823 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Mon, 9 Mar 2026 16:39:31 +0100 Subject: [PATCH] removed commited plan file --- .../2026-03-09-provider-pricing-reports.md | 621 ------------------ 1 file changed, 621 deletions(-) delete mode 100644 docs/plans/2026-03-09-provider-pricing-reports.md diff --git a/docs/plans/2026-03-09-provider-pricing-reports.md b/docs/plans/2026-03-09-provider-pricing-reports.md deleted file mode 100644 index 27471b606..000000000 --- a/docs/plans/2026-03-09-provider-pricing-reports.md +++ /dev/null @@ -1,621 +0,0 @@ -# Provider-Specific Pricing for Reports — Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Replace the hard-coded 0.39 CHF/kWh electricity price with real per-provider tariffs from ELCOM, so report savings reflect each installation's actual network provider rate. - -**Architecture:** New `ProviderTariff` SQLite model stores ELCOM tariff data (total + breakdown) per provider per year. New `PricingService` fetches tariffs via SPARQL, caches in DB, and exposes a lookup method. All three report services (`WeeklyReportService`, `ReportAggregationService`, `ReportEmailService`) replace the hard-coded constant with a dynamic lookup using the installation's `NetworkProvider` field. - -**Tech Stack:** C#/.NET, SQLite-net ORM, SPARQL (ELCOM/LINDAS endpoint), Flurl HTTP - -**Decisions made during brainstorming:** -- Fetch total price now, store breakdown components for future use -- Fetch once per provider+year, store in SQLite (ELCOM tariffs are fixed per year) -- Only new reports going forward use real tariffs (existing stored reports untouched) -- Fallback to 0.39 CHF/kWh if provider has no tariff data -- Scope: accurate savings only (no provider comparison or ROI features yet) - ---- - -## Task 1: Create ProviderTariff Data Model - -**Files:** -- Create: `csharp/App/Backend/DataTypes/ProviderTariff.cs` - -**Step 1: Create the data model** - -```csharp -using SQLite; - -namespace InnovEnergy.App.Backend.DataTypes; - -/// -/// Cached ELCOM electricity tariff for a network provider and year. -/// Fetched from lindas.admin.ch/elcom/electricityprice via SPARQL. -/// Tariffs are fixed per year — fetched once and stored permanently. -/// -public class ProviderTariff -{ - [PrimaryKey, AutoIncrement] - public Int64 Id { get; set; } - - [Indexed] - public String ProviderName { get; set; } = ""; - - [Indexed] - public Int32 Year { get; set; } - - // Total electricity price (CHF/kWh) — used for savings calculation - public Double TotalPricePerKwh { get; set; } - - // Breakdown components (CHF/kWh) — stored for future use - public Double GridUsagePerKwh { get; set; } // Netznutzung - public Double EnergyPerKwh { get; set; } // Energielieferung - public Double FeesPerKwh { get; set; } // Abgaben an Gemeinwesen + KEV/SDL - - public String FetchedAt { get; set; } = ""; -} -``` - -**Step 2: Register table in Db.cs** - -In `csharp/App/Backend/Database/Db.cs`: - -Add table accessor after line 39 (after TicketTimelineEvents): -```csharp -public static TableQuery ProviderTariffs => Connection.Table(); -``` - -Add `CreateTable` call inside the `RunInTransaction` block (after line 77, after TicketTimelineEvent): -```csharp -Connection.CreateTable(); -``` - -**Step 3: Build to verify** - -Run: `cd csharp/App/Backend && dotnet build` -Expected: Build succeeds - -**Step 4: Commit** - -```bash -git add csharp/App/Backend/DataTypes/ProviderTariff.cs csharp/App/Backend/Database/Db.cs -git commit -m "feat: add ProviderTariff data model for ELCOM pricing" -``` - ---- - -## Task 2: Add Database Read/Create Methods for ProviderTariff - -**Files:** -- Modify: `csharp/App/Backend/Database/Read.cs` -- Modify: `csharp/App/Backend/Database/Create.cs` - -**Step 1: Add read method in Read.cs** - -Add at the end of the file (before closing brace): -```csharp -public static ProviderTariff? GetProviderTariff(string providerName, int year) -{ - return ProviderTariffs - .FirstOrDefault(t => t.ProviderName == providerName && t.Year == year); -} -``` - -**Step 2: Add create method in Create.cs** - -Add at the end of the file (before closing brace): -```csharp -public static void InsertProviderTariff(ProviderTariff tariff) -{ - Connection.Insert(tariff); -} -``` - -**Step 3: Build to verify** - -Run: `cd csharp/App/Backend && dotnet build` -Expected: Build succeeds - -**Step 4: Commit** - -```bash -git add csharp/App/Backend/Database/Read.cs csharp/App/Backend/Database/Create.cs -git commit -m "feat: add DB read/create methods for ProviderTariff" -``` - ---- - -## Task 3: Create PricingService - -**Files:** -- Create: `csharp/App/Backend/Services/PricingService.cs` - -**Step 1: Create the service** - -This service: -1. Exposes `GetElectricityPrice(providerName, year)` -> returns CHF/kWh (double) -2. Checks SQLite cache first -3. If not cached, fetches from ELCOM SPARQL and stores -4. Falls back to 0.39 if provider not found or fetch fails - -```csharp -using Flurl.Http; -using InnovEnergy.App.Backend.Database; -using InnovEnergy.App.Backend.DataTypes; -using Newtonsoft.Json.Linq; - -namespace InnovEnergy.App.Backend.Services; - -/// -/// Provides electricity tariffs per network provider and year. -/// Source: ELCOM/LINDAS SPARQL endpoint (Swiss electricity price database). -/// Caches results in SQLite — tariffs are fixed per year. -/// Falls back to 0.39 CHF/kWh if provider data is unavailable. -/// -public static class PricingService -{ - private const double FallbackPricePerKwh = 0.39; - private const string SparqlEndpoint = "https://ld.admin.ch/query"; - - /// - /// Get the total electricity price for a provider in a given year. - /// Returns cached value if available, otherwise fetches from ELCOM. - /// Falls back to 0.39 CHF/kWh if data is unavailable. - /// - public static async Task GetElectricityPriceAsync(string providerName, int year) - { - if (string.IsNullOrWhiteSpace(providerName)) - return FallbackPricePerKwh; - - // Check DB cache first - var cached = Db.GetProviderTariff(providerName, year); - if (cached is not null) - return cached.TotalPricePerKwh; - - // Fetch from ELCOM - var tariff = await FetchTariffFromElcomAsync(providerName, year); - if (tariff is null) - { - Console.WriteLine($"[PricingService] No ELCOM data for '{providerName}' year {year}, using fallback {FallbackPricePerKwh} CHF/kWh."); - return FallbackPricePerKwh; - } - - // Cache in DB - Db.InsertProviderTariff(tariff); - Console.WriteLine($"[PricingService] Cached tariff for '{providerName}' year {year}: {tariff.TotalPricePerKwh:F4} CHF/kWh."); - return tariff.TotalPricePerKwh; - } - - /// - /// Synchronous convenience wrapper for use in report generation. - /// - public static double GetElectricityPrice(string providerName, int year) - { - return GetElectricityPriceAsync(providerName, year).GetAwaiter().GetResult(); - } - - private static async Task FetchTariffFromElcomAsync(string providerName, int year) - { - try - { - // ELCOM SPARQL query for H4 household profile (standard household 4500 kWh/year) - // H4 is the most common reference category for residential installations. - // The query fetches tariff components: gridusage, energy, charge (fees). - // Total = gridusage + energy + charge - var sparqlQuery = $@" -PREFIX schema: -PREFIX cube: -PREFIX elcom: - -SELECT ?gridusage ?energy ?charge -FROM -WHERE {{ - ?obs a cube:Observation ; - elcom:operator ?op ; - elcom:period ; - elcom:category ; - elcom:product ; - elcom:gridusage ?gridusage ; - elcom:energy ?energy ; - elcom:charge ?charge . - ?op schema:name ""{EscapeSparql(providerName)}"" . -}} -LIMIT 1"; - - var response = await SparqlEndpoint - .WithHeader("Accept", "application/sparql-results+json") - .PostUrlEncodedAsync(new { query = sparqlQuery }); - - var json = await response.GetStringAsync(); - var parsed = JObject.Parse(json); - var bindings = parsed["results"]?["bindings"]; - - if (bindings is null || !bindings.Any()) - return null; - - var first = bindings.First(); - var gridUsage = ParseRpToChf(first["gridusage"]?["value"]?.ToString()); - var energy = ParseRpToChf(first["energy"]?["value"]?.ToString()); - var charge = ParseRpToChf(first["charge"]?["value"]?.ToString()); - var total = gridUsage + energy + charge; - - if (total <= 0) - return null; - - return new ProviderTariff - { - ProviderName = providerName, - Year = year, - TotalPricePerKwh = Math.Round(total, 4), - GridUsagePerKwh = Math.Round(gridUsage, 4), - EnergyPerKwh = Math.Round(energy, 4), - FeesPerKwh = Math.Round(charge, 4), - FetchedAt = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") - }; - } - catch (Exception ex) - { - Console.Error.WriteLine($"[PricingService] ELCOM fetch failed for '{providerName}' year {year}: {ex.Message}"); - return null; - } - } - - /// - /// ELCOM values may be in Rp./kWh (centimes) or CHF/kWh. - /// Values > 1 are likely Rp./kWh and need /100 conversion. - /// Values <= 1 are already CHF/kWh. - /// - private static double ParseRpToChf(string? value) - { - if (!double.TryParse(value, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out var parsed)) - return 0; - - // ELCOM typically returns Rp./kWh (centimes), convert to CHF - return parsed > 1 ? parsed / 100.0 : parsed; - } - - private static string EscapeSparql(string value) - { - return value.Replace("\\", "\\\\").Replace("\"", "\\\""); - } -} -``` - -**Important: SPARQL query validation required** -- The exact SPARQL predicates (`elcom:gridusage`, `elcom:energy`, `elcom:charge`) need verification against the live endpoint before implementing. -- Test manually first: - ```bash - curl -X POST https://ld.admin.ch/query \ - -H "Accept: application/sparql-results+json" \ - -d "query=PREFIX schema: PREFIX cube: PREFIX elcom: SELECT ?gridusage ?energy ?charge FROM WHERE { ?obs a cube:Observation ; elcom:operator ?op ; elcom:period ; elcom:category ; elcom:product ; elcom:gridusage ?gridusage ; elcom:energy ?energy ; elcom:charge ?charge . ?op schema:name \"BKW Energie AG\" . } LIMIT 1" - ``` -- If predicates or URIs differ, adjust `PricingService.cs` accordingly. -- The H4 category (standard household, 4500 kWh/year) is the best default for residential Sodistore installations. -- ELCOM docs: https://www.elcom.admin.ch/elcom/en/home/open-data-api.html - -**Step 2: Build to verify** - -Run: `cd csharp/App/Backend && dotnet build` -Expected: Build succeeds - -**Step 3: Commit** - -```bash -git add csharp/App/Backend/Services/PricingService.cs -git commit -m "feat: add PricingService for ELCOM tariff lookup with DB caching" -``` - ---- - -## Task 4: Verify SPARQL Query Against Live Endpoint - -**This is a critical validation step before integrating into reports.** - -**Step 1: Test the SPARQL query** - -Run the curl command from Task 3 notes against the live ELCOM endpoint with a known provider name (e.g., one from the existing `NetworkProviderService` list). - -**Step 2: Inspect the response** - -Check: -- Are the predicate names correct (`elcom:gridusage`, `elcom:energy`, `elcom:charge`)? -- What units are the values in (Rp./kWh or CHF/kWh)? -- Does the H4 category exist? -- Does the year URI format `` work? - -**Step 3: Adjust PricingService if needed** - -If predicates, URIs, or units differ from what's in the code, update the SPARQL query and unit conversion logic in `PricingService.cs`. - -**Step 4: Commit any fixes** - -```bash -git add csharp/App/Backend/Services/PricingService.cs -git commit -m "fix: adjust SPARQL query to match live ELCOM endpoint" -``` - ---- - -## Task 5: Integrate Pricing into WeeklyReportService - -**Files:** -- Modify: `csharp/App/Backend/Services/WeeklyReportService.cs` - -**Step 1: Replace hard-coded price in savings calculation (around line 181)** - -Before: -```csharp -const double ElectricityPriceCHF = 0.39; -var totalEnergySaved = Math.Round(currentSummary.TotalConsumption - currentSummary.TotalGridImport, 1); -var totalSavingsCHF = Math.Round(totalEnergySaved * ElectricityPriceCHF, 0); -``` - -After: -```csharp -var installation = Db.GetInstallationById(installationId); -var reportYear = DateTime.Parse(currentWeekDays.First().Date).Year; -var electricityPrice = await PricingService.GetElectricityPriceAsync( - installation?.NetworkProvider ?? "", reportYear); -var totalEnergySaved = Math.Round(currentSummary.TotalConsumption - currentSummary.TotalGridImport, 1); -var totalSavingsCHF = Math.Round(totalEnergySaved * electricityPrice, 0); -``` - -Note: The `installation` variable may already exist earlier in the method. If so, reuse it instead of fetching again. Check the method context. - -**Step 2: Replace hard-coded price in AI prompt section (around line 265)** - -Before: -```csharp -const double ElectricityPriceCHF = 0.39; -``` - -After: Remove this line. Use the `electricityPrice` variable from step 1 (pass it as a parameter to the AI insight method, or compute it there). - -The key point: wherever `ElectricityPriceCHF` appears, replace with the dynamic value. - -**Step 3: Build to verify** - -Run: `cd csharp/App/Backend && dotnet build` -Expected: Build succeeds - -**Step 4: Commit** - -```bash -git add csharp/App/Backend/Services/WeeklyReportService.cs -git commit -m "feat: use provider-specific tariff in weekly report savings" -``` - ---- - -## Task 6: Integrate Pricing into ReportAggregationService - -**Files:** -- Modify: `csharp/App/Backend/Services/ReportAggregationService.cs` - -**Step 1: Remove the class-level constant (line 14)** - -Before: -```csharp -private const Double ElectricityPriceCHF = 0.39; -``` - -After: Remove this line entirely. - -**Step 2: Update monthly aggregation (around line 366)** - -The method already has `installationId` and fetches the installation. Add pricing lookup: - -Before: -```csharp -var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0); -``` - -After: -```csharp -var electricityPrice = await PricingService.GetElectricityPriceAsync( - installation?.NetworkProvider ?? "", year); -var savingsCHF = Math.Round(energySaved * electricityPrice, 0); -``` - -Note: `installation` is already fetched at line 373 (`Db.GetInstallationById(installationId)`). Move the fetch before the savings calculation if needed, or reuse. - -**Step 3: Update yearly aggregation (around line 477)** - -Before: -```csharp -var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0); -``` - -After: -```csharp -var electricityPrice = await PricingService.GetElectricityPriceAsync( - installation?.NetworkProvider ?? "", year); -var savingsCHF = Math.Round(energySaved * electricityPrice, 0); -``` - -Note: `installation` is already fetched at line 488. Same pattern as monthly. - -**Step 4: Update AI prompt references** - -Search for any remaining `ElectricityPriceCHF` references in the file (AI prompt strings around lines 693, 728). Replace with the dynamic value passed into the AI generation methods. - -**Step 5: Build to verify** - -Run: `cd csharp/App/Backend && dotnet build` -Expected: Build succeeds - -**Step 6: Commit** - -```bash -git add csharp/App/Backend/Services/ReportAggregationService.cs -git commit -m "feat: use provider-specific tariff in monthly/yearly report savings" -``` - ---- - -## Task 7: Update Email Templates with Dynamic Price - -**Files:** -- Modify: `csharp/App/Backend/Services/ReportEmailService.cs` - -**Step 1: Make `AtRate` field dynamic** - -The `EmailStrings` and `AggregatedEmailStrings` records have an `AtRate` field with hard-coded "bei 0.39 CHF/kWh" / "a 0.39 CHF/kWh" / etc. - -Change the approach: instead of hard-coding the rate in the language strings, pass the rate as a parameter and format it dynamically. - -Update the `GetWeeklyStrings` method to accept a `double electricityPrice` parameter: - -Before (example for German, line 106): -```csharp -AtRate: "bei 0.39 CHF/kWh", -``` - -After: -```csharp -AtRate: $"bei {electricityPrice:F2} CHF/kWh", -``` - -Apply the same pattern for all 4 languages (de, fr, it, en) in both: -- `GetWeeklyStrings()` — 4 language variants (lines ~90-200) -- `GetAggregatedStrings()` — 8 language+type variants (lines ~524-574) - -The preposition varies by language: -- German: `"bei {price:F2} CHF/kWh"` -- French: `"a {price:F2} CHF/kWh"` -- Italian: `"a {price:F2} CHF/kWh"` -- English: `"at {price:F2} CHF/kWh"` - -**Step 2: Update callers to pass the price** - -Wherever `GetWeeklyStrings(language)` or `GetAggregatedStrings(language, type)` is called, pass the electricity price as an additional parameter. The price should come from the report generation context (already computed in Tasks 5-6). - -**Step 3: Build to verify** - -Run: `cd csharp/App/Backend && dotnet build` -Expected: Build succeeds - -**Step 4: Commit** - -```bash -git add csharp/App/Backend/Services/ReportEmailService.cs -git commit -m "feat: display actual provider tariff rate in report emails" -``` - ---- - -## Task 8: Add GetProviderTariff API Endpoint (Optional but Recommended) - -**Files:** -- Modify: `csharp/App/Backend/Controller.cs` - -**Step 1: Add endpoint for frontend to query tariff** - -This allows the frontend Information tab to display the current tariff next to the provider selector. - -Add in Controller.cs (near the existing `GetNetworkProviders` endpoint around line 756): - -```csharp -[HttpGet(nameof(GetProviderTariff))] -public async Task> GetProviderTariff(Token authToken, string providerName, int year) -{ - var session = Db.GetSession(authToken); - if (session is null) - return Unauthorized(); - - var price = await PricingService.GetElectricityPriceAsync(providerName, year); - var tariff = Db.GetProviderTariff(providerName, year); - - return Ok(new - { - providerName, - year, - totalPricePerKwh = price, - gridUsagePerKwh = tariff?.GridUsagePerKwh ?? 0, - energyPerKwh = tariff?.EnergyPerKwh ?? 0, - feesPerKwh = tariff?.FeesPerKwh ?? 0, - isFallback = tariff is null - }); -} -``` - -**Step 2: Build to verify** - -Run: `cd csharp/App/Backend && dotnet build` -Expected: Build succeeds - -**Step 3: Commit** - -```bash -git add csharp/App/Backend/Controller.cs -git commit -m "feat: add GetProviderTariff API endpoint" -``` - ---- - -## Task 9: End-to-End Verification - -**Step 1: Full build** - -Run: `cd csharp/App/Backend && dotnet build` -Expected: Build succeeds with no warnings related to our changes - -**Step 2: Test SPARQL query manually** - -Pick 2-3 known provider names from the ELCOM list and verify the pricing query returns sensible values (typical Swiss residential rates are 0.20-0.45 CHF/kWh). - -**Step 3: Verify fallback behavior** - -Test with: -- Empty provider name -> should return 0.39 -- Unknown provider name -> should return 0.39 -- Valid provider -> should return actual ELCOM rate - -**Step 4: Review all changes** - -```bash -git diff main --stat -git log --oneline main..HEAD -``` - -Verify: -- No remaining hard-coded 0.39 in report calculation code -- Email templates use dynamic formatting -- ProviderTariff table is registered in Db.cs -- PricingService has proper error handling and fallback - ---- - -## Summary of All Files Changed - -| File | Action | Purpose | -|------|--------|---------| -| `DataTypes/ProviderTariff.cs` | Create | SQLite model for cached tariffs | -| `Database/Db.cs` | Modify | Register table + accessor | -| `Database/Read.cs` | Modify | Add `GetProviderTariff()` query | -| `Database/Create.cs` | Modify | Add `InsertProviderTariff()` | -| `Services/PricingService.cs` | Create | ELCOM fetch + cache + fallback logic | -| `Services/WeeklyReportService.cs` | Modify | Use dynamic price (2 places) | -| `Services/ReportAggregationService.cs` | Modify | Use dynamic price (monthly + yearly) | -| `Services/ReportEmailService.cs` | Modify | Dynamic rate in 12 language strings | -| `Controller.cs` | Modify | Optional: GetProviderTariff endpoint | - -## Fallback Behavior - -| Scenario | Behavior | -|----------|----------| -| No NetworkProvider set on installation | Uses 0.39 CHF/kWh | -| Provider not found in ELCOM | Uses 0.39 CHF/kWh, logs warning | -| ELCOM endpoint unavailable | Uses 0.39 CHF/kWh, logs error | -| Tariff already cached in DB | Returns cached value (no network call) | -| New year, same provider | Fetches new year's tariff from ELCOM | - -## Future Extensions (Not In Scope) - -- Provider comparison ("your rate vs. average") -- ROI/payback calculation -- Time-of-use / dynamic spot pricing -- Tariff breakdown display in reports -- Category selection (H1-H8 profiles beyond default H4)