21 KiB
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
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):
public static TableQuery<ProviderTariff> ProviderTariffs => Connection.Table<ProviderTariff>();
Add CreateTable call inside the RunInTransaction block (after line 77, after TicketTimelineEvent):
Connection.CreateTable<ProviderTariff>();
Step 3: Build to verify
Run: cd csharp/App/Backend && dotnet build
Expected: Build succeeds
Step 4: Commit
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):
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):
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
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:
- Exposes
GetElectricityPrice(providerName, year)-> returns CHF/kWh (double) - Checks SQLite cache first
- If not cached, fetches from ELCOM SPARQL and stores
- Falls back to 0.39 if provider not found or fetch fails
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:
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.csaccordingly. - 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
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
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:
const double ElectricityPriceCHF = 0.39;
var totalEnergySaved = Math.Round(currentSummary.TotalConsumption - currentSummary.TotalGridImport, 1);
var totalSavingsCHF = Math.Round(totalEnergySaved * ElectricityPriceCHF, 0);
After:
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:
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
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:
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:
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
After:
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:
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
After:
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
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):
AtRate: "bei 0.39 CHF/kWh",
After:
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
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):
[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
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
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)