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