ROI & Financial Analytics
The ROI subsystem calculates return on investment for solar installations by correlating actual energy production data with historical electricity pricing.
Concept
Ampra computes financial returns by answering: "How much money has this solar installation saved by producing energy that would otherwise have been purchased from the grid?"
Total Savings = Σ (daily_energy_produced_kWh × applicable_price_per_kWh)
ROI % = (Total Savings / System Cost) × 100
Payback Period = System Cost / Monthly Average Savings × 12 (in years)
KWh Price History
Electricity prices change over time. Ampra tracks these changes through a price history timeline:
Price Management API
| Endpoint | Method | Description |
|---|---|---|
GET /api/roi/{sourceId}/prices | GET | List all price entries ordered by effective date |
POST /api/roi/{sourceId}/prices | POST | Create a new price entry |
PUT /api/roi/prices/{priceId} | PUT | Update an existing price entry |
DELETE /api/roi/prices/{priceId} | DELETE | Remove a price entry |
Price CRUD Implementation
All price operations verify source ownership before proceeding:
public async Task<IEnumerable<KwhPriceHistoryDto>> GetPriceHistoryAsync(
Guid sunSourceId, string userId)
{
if (!await _ownershipService.IsSourceOwnedByUserAsync(sunSourceId, userId))
return Enumerable.Empty<KwhPriceHistoryDto>();
var prices = await _context.KwhPriceHistories
.Where(p => p.SunSourceId == sunSourceId)
.OrderBy(p => p.EffectiveFrom)
.ToListAsync();
return prices.Select(p => p.Adapt<KwhPriceHistoryDto>());
}
public async Task<KwhPriceHistoryDto> CreatePriceEntryAsync(
Guid sunSourceId, CreateKwhPriceRequest request, string userId)
{
await _ownershipService.GetOwnedSourceOrThrowAsync(sunSourceId, userId);
var entry = new KwhPriceHistory
{
SunSourceId = sunSourceId,
PricePerKwh = request.PricePerKwh,
EffectiveFrom = request.EffectiveFrom,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.KwhPriceHistories.Add(entry);
await _context.SaveChangesAsync();
return entry.Adapt<KwhPriceHistoryDto>();
}
Update and delete operations use the Include(p => p.SunSource) navigation to verify ownership through the join:
public async Task<KwhPriceHistoryDto?> UpdatePriceEntryAsync(
Guid priceId, UpdateKwhPriceRequest request, string userId)
{
var entry = await _context.KwhPriceHistories
.Include(p => p.SunSource)
.FirstOrDefaultAsync(p => p.Id == priceId && p.SunSource.UserId == userId);
if (entry == null) return null;
entry.PricePerKwh = request.PricePerKwh;
entry.EffectiveFrom = request.EffectiveFrom;
entry.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return entry.Adapt<KwhPriceHistoryDto>();
}
Price Entry Schema
{
"pricePerKwh": 0.32,
"effectiveFrom": "2026-03-01T00:00:00Z"
}
Validation:
PricePerKwhmust be ≥ 0EffectiveFrommust be a valid date
ROI Calculation Engine
Endpoint
GET /api/roi/{sunSourceId}/calculate
Calculation Pipeline
Response Schema
{
"totalEnergyProducedKwh": 2450.8,
"totalSavings": 784.26,
"currency": "EUR",
"firstDataDate": "2026-01-15T00:00:00Z",
"lastDataDate": "2026-03-09T00:00:00Z",
"systemCost": 12000.00,
"roiPercentage": 6.54,
"paybackPeriodYears": 6.8,
"monthlyAvgSavings": 147.05,
"hasInvestmentData": true,
"dailyBreakdown": [
{
"date": "2026-01-15T00:00:00Z",
"energyProducedKwh": 12.5,
"pricePerKwh": 0.25,
"savings": 3.13,
"cumulativeSavings": 3.13,
"isPrediction": false
},
{
"date": "2026-01-16T00:00:00Z",
"energyProducedKwh": 15.2,
"pricePerKwh": 0.25,
"savings": 3.80,
"cumulativeSavings": 6.93,
"isPrediction": false
}
]
}
Financial Data Requirements
| Field | Source | Required for ROI% |
|---|---|---|
SystemCost | SunSource entity (user input) | Yes |
MaintenanceCostPerYear | SunSource entity (user input) | Optional |
InstallationDate | SunSource entity (user input) | Optional |
Currency | SunSource entity (default: EUR) | — |
| KWh prices | KwhPriceHistory table | Yes (at least one entry) |
If SystemCost is not set, roiPercentage and paybackPeriodYears will be null, and hasInvestmentData will be false.
Public ROI Access
GET /api/sharing/public/{sunSourceId}/roi
The ROI calculation can be publicly shared if the source owner has enabled ShareReturns in their sharing settings. This endpoint calls CalculateRoiPublicAsync, which is identical to the authenticated version but bypasses ownership validation:
// Authenticated: validates ownership via IOwnershipService
public async Task<RoiSummaryDto> CalculateRoiAsync(Guid sunSourceId, string userId)
{
var sunSource = await _ownershipService.GetOwnedSourceOrThrowAsync(sunSourceId, userId);
return await CalculateRoiInternalAsync(sunSource);
}
// Public: skips ownership check for shared sources
public async Task<RoiSummaryDto> CalculateRoiPublicAsync(Guid sunSourceId)
{
var sunSource = await _context.SunSources
.FirstOrDefaultAsync(ss => ss.Id == sunSourceId);
if (sunSource == null)
throw new NotFoundException("Sun source not found");
return await CalculateRoiInternalAsync(sunSource);
}
Both methods delegate to the same CalculateRoiInternalAsync for the actual calculation.
Energy Data Aggregation
The ROI engine uses MongoDB's aggregation pipeline to extract daily energy production. Here is the actual C# implementation:
CalculateRoiInternalAsync — Full Implementation
private async Task<RoiSummaryDto> CalculateRoiInternalAsync(SunSource sunSource)
{
var sunSourceId = sunSource.Id;
// Step 1: Load price history from PostgreSQL
var prices = await _context.KwhPriceHistories
.Where(p => p.SunSourceId == sunSourceId)
.OrderBy(p => p.EffectiveFrom)
.ToListAsync();
if (prices.Count == 0)
{
return new RoiSummaryDto
{
Currency = sunSource.Currency,
HasInvestmentData = sunSource.SystemCost.HasValue
};
}
// Step 2: MongoDB aggregation — group by day, get max/min daily energy
var dailyEnergyResults = await _normalizedDataCollection.Aggregate()
.Match(Builders<NormalizedSunSourceData>.Filter.Eq(d => d.SunSourceId, sunSourceId))
.AppendStage<BsonDocument>(new BsonDocument("$group", new BsonDocument
{
{ "_id", new BsonDocument
{
{ "year", new BsonDocument("$year", "$timestamp") },
{ "month", new BsonDocument("$month", "$timestamp") },
{ "day", new BsonDocument("$dayOfMonth", "$timestamp") }
}
},
{ "maxDailyEnergy", new BsonDocument("$max", "$dailyEnergyProduced") },
{ "minDailyEnergy", new BsonDocument("$min", "$dailyEnergyProduced") }
}))
.AppendStage<BsonDocument>(new BsonDocument("$sort", new BsonDocument
{
{ "_id.year", 1 }, { "_id.month", 1 }, { "_id.day", 1 }
}))
.ToListAsync();
// Step 3: Match prices to production dates and calculate savings
var dailyBreakdown = new List<RoiDailyDto>();
var cumulativeSavings = 0m;
var totalEnergy = 0m;
var priceIndex = 0;
foreach (var doc in dailyEnergyResults)
{
var id = doc["_id"].AsBsonDocument;
var date = new DateTime(
id["year"].ToInt32(), id["month"].ToInt32(), id["day"].ToInt32(),
0, 0, 0, DateTimeKind.Utc);
var maxVal = doc["maxDailyEnergy"].IsBsonNull ? 0.0
: doc["maxDailyEnergy"].ToDouble();
var minVal = doc["minDailyEnergy"].IsBsonNull ? 0.0
: doc["minDailyEnergy"].ToDouble();
// Net daily production = max - min (cumulative counter reset handling)
var energyKwh = Math.Round((decimal)Math.Max(0, maxVal - minVal), 4);
// Advance price pointer to find applicable price for this date
while (priceIndex < prices.Count - 1
&& prices[priceIndex + 1].EffectiveFrom <= date)
priceIndex++;
var applicablePrice = prices[priceIndex];
var savings = Math.Round(energyKwh * applicablePrice.PricePerKwh, 4);
cumulativeSavings += savings;
totalEnergy += energyKwh;
dailyBreakdown.Add(new RoiDailyDto
{
Date = date,
EnergyProducedKwh = Math.Round(energyKwh, 2),
PricePerKwh = applicablePrice.PricePerKwh,
Savings = Math.Round(savings, 2),
CumulativeSavings = Math.Round(cumulativeSavings, 2)
});
}
// Step 4: Compute financial metrics
decimal? roiPercentage = null;
decimal? paybackPeriodYears = null;
decimal? monthlyAvgSavings = null;
if (dailyBreakdown.Count > 0)
{
var totalDays = (dailyBreakdown[^1].Date - dailyBreakdown[0].Date).TotalDays + 1;
var totalMonths = (decimal)totalDays / 30.44m;
monthlyAvgSavings = totalMonths > 0
? Math.Round(cumulativeSavings / totalMonths, 2) : null;
if (sunSource.SystemCost.HasValue && sunSource.SystemCost.Value > 0)
{
var systemCost = sunSource.SystemCost.Value;
var yearsOfData = (decimal)totalDays / 365.25m;
var maintenanceCostTotal = (sunSource.MaintenanceCostPerYear ?? 0m) * yearsOfData;
var netReturn = cumulativeSavings - maintenanceCostTotal;
// ROI% = ((net_return - system_cost) / system_cost) × 100
roiPercentage = Math.Round(((netReturn - systemCost) / systemCost) * 100m, 2);
// Payback = system_cost / net_annual_savings
var annualSavings = yearsOfData > 0 ? cumulativeSavings / yearsOfData : 0m;
var netAnnualSavings = annualSavings - (sunSource.MaintenanceCostPerYear ?? 0m);
paybackPeriodYears = netAnnualSavings > 0
? Math.Round(systemCost / netAnnualSavings, 1) : null;
}
}
return new RoiSummaryDto { /* all computed fields */ };
}
Key Implementation Details
| Aspect | Detail |
|---|---|
| Energy calculation | MAX - MIN per day (handles cumulative counters that may reset) |
| Price matching | Linear scan with advancing pointer — O(n) because prices are pre-sorted |
| Maintenance costs | Deducted proportionally based on data timespan |
| ROI formula | ((savings - maintenance - systemCost) / systemCost) × 100% |
| Payback formula | systemCost / (annualSavings - annualMaintenance) |
| Null safety | Returns null for ROI%/payback when SystemCost is not set |
The MAX(dailyEnergyProduced) minus MIN(dailyEnergyProduced) approach handles solar inverters that report cumulative daily energy — the difference captures the actual production even if the counter resets mid-day.