Skip to main content

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

EndpointMethodDescription
GET /api/roi/{sourceId}/pricesGETList all price entries ordered by effective date
POST /api/roi/{sourceId}/pricesPOSTCreate a new price entry
PUT /api/roi/prices/{priceId}PUTUpdate an existing price entry
DELETE /api/roi/prices/{priceId}DELETERemove 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:

  • PricePerKwh must be ≥ 0
  • EffectiveFrom must 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

FieldSourceRequired for ROI%
SystemCostSunSource entity (user input)Yes
MaintenanceCostPerYearSunSource entity (user input)Optional
InstallationDateSunSource entity (user input)Optional
CurrencySunSource entity (default: EUR)
KWh pricesKwhPriceHistory tableYes (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

AspectDetail
Energy calculationMAX - MIN per day (handles cumulative counters that may reset)
Price matchingLinear scan with advancing pointer — O(n) because prices are pre-sorted
Maintenance costsDeducted proportionally based on data timespan
ROI formula((savings - maintenance - systemCost) / systemCost) × 100%
Payback formulasystemCost / (annualSavings - annualMaintenance)
Null safetyReturns 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.