Innovenergy_trunk/docs/plans/2026-03-09-provider-pricing...

622 lines
21 KiB
Markdown

# 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;
/// <summary>
/// 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.
/// </summary>
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<ProviderTariff> ProviderTariffs => Connection.Table<ProviderTariff>();
```
Add `CreateTable` call inside the `RunInTransaction` block (after line 77, after TicketTimelineEvent):
```csharp
Connection.CreateTable<ProviderTariff>();
```
**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;
/// <summary>
/// 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.
/// </summary>
public static class PricingService
{
private const double FallbackPricePerKwh = 0.39;
private const string SparqlEndpoint = "https://ld.admin.ch/query";
/// <summary>
/// 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.
/// </summary>
public static async Task<double> 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;
}
/// <summary>
/// Synchronous convenience wrapper for use in report generation.
/// </summary>
public static double GetElectricityPrice(string providerName, int year)
{
return GetElectricityPriceAsync(providerName, year).GetAwaiter().GetResult();
}
private static async Task<ProviderTariff?> 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: <http://schema.org/>
PREFIX cube: <https://cube.link/>
PREFIX elcom: <https://energy.ld.admin.ch/elcom/electricityprice/dimension/>
SELECT ?gridusage ?energy ?charge
FROM <https://lindas.admin.ch/elcom/electricityprice>
WHERE {{
?obs a cube:Observation ;
elcom:operator ?op ;
elcom:period <https://ld.admin.ch/time/year/{year}> ;
elcom:category <https://energy.ld.admin.ch/elcom/electricityprice/category/H4> ;
elcom:product <https://energy.ld.admin.ch/elcom/electricityprice/product/total> ;
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;
}
}
/// <summary>
/// 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.
/// </summary>
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: <http://schema.org/> PREFIX cube: <https://cube.link/> PREFIX elcom: <https://energy.ld.admin.ch/elcom/electricityprice/dimension/> SELECT ?gridusage ?energy ?charge FROM <https://lindas.admin.ch/elcom/electricityprice> WHERE { ?obs a cube:Observation ; elcom:operator ?op ; elcom:period <https://ld.admin.ch/time/year/2025> ; elcom:category <https://energy.ld.admin.ch/elcom/electricityprice/category/H4> ; elcom:product <https://energy.ld.admin.ch/elcom/electricityprice/product/total> ; 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 `<https://ld.admin.ch/time/year/2025>` 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<ActionResult<object>> 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)