Services Reference
Complete reference for every service interface and its implementation in the Ampra platform. Services follow a clean-architecture pattern: interfaces live in Ampra.Application, implementations in Ampra.Infrastructure, and dependency injection is configured in Ampra.Web/Startup.cs.
Dependency Injection Registration
All services are registered in Startup.ConfigureServices:
// Scoped services (per-request lifetime)
services.AddScoped<IOwnershipService, OwnershipService>();
services.AddScoped<IStorageService, MinIOStorageService>();
services.AddScoped<IEmailService, SendGridEmailService>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAdminService, AdminService>();
services.AddScoped<IPowerGroupService, PowerGroupService>();
services.AddScoped<ISunSourceService, SunSourceService>();
services.AddScoped<ISunSourceDataService, SunSourceDataService>();
services.AddScoped<ISunSourceMetricsService, SunSourceMetricsService>();
services.AddScoped<ISunSourceDataIngestionService, SunSourceDataIngestionService>();
services.AddScoped<IUserSettingService, UserSettingService>();
services.AddScoped<IRoiService, RoiService>();
services.AddScoped<ISharingService, SharingService>();
services.AddScoped<IMqttAuthService, MqttAuthService>();
services.AddScoped<IExportService, ExportService>();
// Typed HttpClients
services.AddHttpClient<IWeatherService, WeatherService>();
services.AddHttpClient<IPredictionService, PredictionService>(client =>
{
client.BaseAddress = new Uri(Configuration[ConfigKeys.MlServiceUrl] ?? "http://localhost:5050");
client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.Add(HttpHeaderNames.ApiKey,
Configuration[ConfigKeys.MlApiKey] ?? "changeme-ml-key");
});
// Singleton (application lifetime) — Debug data generator
services.AddSingleton<DebugProcessService>();
services.AddSingleton<IDebugProcessService>(sp => sp.GetRequiredService<DebugProcessService>());
services.AddHostedService(provider => provider.GetRequiredService<DebugProcessService>());
IAuthService
Implementation: AuthService · Lifetime: Scoped · Dependencies: UserManager<ApplicationUser>, SignInManager<ApplicationUser>, IEmailService, IConfiguration, IUserSettingService
Handles the complete user lifecycle — registration, email verification, login, session management.
Methods
| Method | Return Type | Description |
|---|---|---|
RegisterAsync(RegisterRequest) | Task<AuthResponse> | Create user, assign role, send verification email |
VerifyEmailAsync(VerifyEmailRequest) | Task<AuthResponse> | Verify 6-digit code, create session |
ResendVerificationCodeAsync(ResendCodeRequest) | Task<AuthResponse> | Generate new code with 60s cooldown |
LoginAsync(LoginRequest) | Task<AuthResponse> | Authenticate and create cookie session |
LogoutAsync() | Task<AuthResponse> | Sign out and clear cookie |
GetCurrentUserAsync(string userId) | Task<AuthResponse> | Return current user info with roles |
Key Implementation Details
Registration generates a cryptographically secure 6-digit code and assigns the Overseer role if the email matches the configured Auth:OverseerEmail:
public async Task<AuthResponse> RegisterAsync(RegisterRequest request)
{
var user = new ApplicationUser
{
UserName = request.Email,
Email = request.Email,
FirstName = request.FirstName,
LastName = request.LastName,
CreatedAt = DateTime.UtcNow
};
var result = await _userManager.CreateAsync(user, request.Password);
if (result.Succeeded)
{
// Overseer role assignment
var overseerEmail = _configuration[ConfigKeys.AuthOverseerEmail];
var roleToAssign = !string.IsNullOrEmpty(overseerEmail) &&
user.Email?.Equals(overseerEmail, StringComparison.OrdinalIgnoreCase) == true
? Roles.Overseer
: Roles.User;
await _userManager.AddToRoleAsync(user, roleToAssign);
// Generate 6-digit verification code (CSPRNG)
var code = RandomNumberGenerator.GetInt32(100000, 1000000).ToString();
user.EmailVerificationCode = code;
user.EmailVerificationCodeExpiry = DateTime.UtcNow.AddMinutes(10);
await _userManager.UpdateAsync(user);
// Create default user settings
await _userSettingService.UpsertSettingAsync(
user.Id, (int)SettingType.AutoUpdateWeatherData, "0");
await _emailService.SendVerificationCodeAsync(user.Email!, code, user.FirstName ?? "User");
}
// ...
}
Verification includes lockout after 5 failed attempts:
public async Task<AuthResponse> VerifyEmailAsync(VerifyEmailRequest request)
{
var user = await _userManager.FindByEmailAsync(request.Email);
// Check lockout
if (user.VerificationLockoutUntil.HasValue && user.VerificationLockoutUntil > DateTime.UtcNow)
return new AuthResponse { Success = false, Message = "Too many failed attempts." };
// Check expiry (10-minute window)
if (user.EmailVerificationCodeExpiry < DateTime.UtcNow)
return new AuthResponse { Success = false, Message = "Verification code has expired." };
if (user.EmailVerificationCode != request.Code)
{
user.FailedVerificationAttempts++;
if (user.FailedVerificationAttempts >= 5)
{
user.VerificationLockoutUntil = DateTime.UtcNow.AddMinutes(15);
user.FailedVerificationAttempts = 0;
}
await _userManager.UpdateAsync(user);
return new AuthResponse { Success = false, Message = "Invalid verification code" };
}
// Success — clear verification fields, sign in, send welcome email
user.IsEmailVerified = true;
user.EmailVerificationCode = null;
await _signInManager.SignInAsync(user, isPersistent: false);
await _emailService.SendWelcomeEmailAsync(user.Email!, user.FirstName ?? "User");
// ...
}
Login uses ASP.NET Identity's built-in lockout mechanism:
var result = await _signInManager.PasswordSignInAsync(
user.UserName!, request.Password, request.RememberMe, lockoutOnFailure: true);
if (result.Succeeded) { /* return user info with roles */ }
if (result.IsLockedOut) { /* return lockout message */ }
IAdminService
Implementation: AdminService · Lifetime: Scoped · Dependencies: UserManager<ApplicationUser>
Administrative user management — paginated listing, role assignment, and deletion with safety guards.
Methods
| Method | Return Type | Description |
|---|---|---|
GetAllUsersAsync(int page, int pageSize, string? search) | Task<UsersResponse> | Paginated user list with search |
UpdateUserAsync(string id, UpdateUserRequest, string callerId) | Task<UserResponse> | Update name and roles |
ConfirmEmailAsync(string id) | Task<UserResponse> | Force-confirm a user's email |
DeleteUserAsync(string id, string callerId) | Task<UserResponse> | Delete user with protections |
Safety Guards
// Cannot modify own account
if (id == callerUserId)
return new UserResponse { Success = false, Message = "Cannot modify your own account through admin panel" };
// Only Overseer can assign Overseer role
if (request.Roles.Contains(Roles.Overseer) && !isCallerOverseer)
return new UserResponse { Success = false, Message = "Only an Overseer can assign the Overseer role" };
// Cannot delete Overseer account
if (roles.Contains(Roles.Overseer))
return new UserResponse { Success = false, Message = "Cannot delete the Overseer account." };
Role Update Logic
Roles are diffed and applied atomically — if adding roles fails, removed roles are restored:
var currentRoles = await _userManager.GetRolesAsync(user);
var rolesToRemove = currentRoles.Except(request.Roles).ToList();
var rolesToAdd = request.Roles.Except(currentRoles).ToList();
if (rolesToRemove.Any())
await _userManager.RemoveFromRolesAsync(user, rolesToRemove);
if (rolesToAdd.Any())
{
var addResult = await _userManager.AddToRolesAsync(user, rolesToAdd);
if (!addResult.Succeeded)
await _userManager.AddToRolesAsync(user, rolesToRemove); // Rollback
}
ISunSourceService
Implementation: SunSourceService · Lifetime: Scoped · Dependencies: ApplicationDbContext, IHttpContextAccessor, IMongoDatabase, IOptions<MqttBrokerSettings>
Full CRUD for solar installations with automatic credential generation for MQTT and Webhook connection types.
Methods
| Method | Return Type | Description |
|---|---|---|
GetUserSunSourcesAsync(string userId, Guid? powerGroupId) | Task<IEnumerable<SunSourceDto>> | List user's sources, optional filter |
GetSunSourceByIdAsync(Guid id, string userId, bool includeConnectionDetails) | Task<SunSourceDto?> | Get source with optional credentials |
CreateSunSourceAsync(CreateSunSourceRequest, string userId) | Task<SunSourceDto> | Create with auto-generated MQTT/Webhook creds |
UpdateSunSourceAsync(Guid id, UpdateSunSourceRequest, string userId) | Task<SunSourceDto?> | Update mutable fields |
DeleteSunSourceAsync(Guid id, string userId) | Task<bool> | Delete from PostgreSQL + cleanup MongoDB |
GenerateWebhookUrlAsync(Guid sunSourceId, string userId) | Task<string> | Get webhook URL |
RotateWebhookSecretAsync(Guid sunSourceId, string userId) | Task<string> | Generate new HMAC secret |
RegenerateMqttApiKeyAsync(Guid sunSourceId, string userId) | Task<string> | Generate new MQTT API key |
Connection-Type-Specific Creation
When a source is created, credentials are automatically generated based on the connection type:
public async Task<SunSourceDto> CreateSunSourceAsync(CreateSunSourceRequest request, string userId)
{
var sunSource = request.Adapt<SunSource>();
sunSource.UserId = userId;
sunSource.ConnectionType = connectionType;
sunSource.Latitude = request.Latitude is >= -90 and <= 90 ? request.Latitude : null;
sunSource.Longitude = request.Longitude is >= -180 and <= 180 ? request.Longitude : null;
sunSource.Currency = request.Currency ?? Defaults.Currency;
if (connectionType == SunSourceConnectionType.MQTT)
{
sunSource.MqttTopic = MqttTopics.ForSource(sunSource.Id); // "ampra/sources/{id}/data"
sunSource.MqttApiKey = GenerateApiKey(); // 32-byte hex
}
else if (connectionType == SunSourceConnectionType.Webhook)
{
sunSource.WebhookSecret = request.WebhookSecret ?? GenerateWebhookSecret(); // 32-byte Base64
sunSource.WebhookUrl = GenerateWebhookUrl(sunSource.Id);
}
// Optional initial kWh price entry
if (request.InitialKwhPrice.HasValue && request.InitialKwhPrice.Value > 0)
{
_context.KwhPriceHistories.Add(new KwhPriceHistory
{
SunSourceId = sunSource.Id,
PricePerKwh = (decimal)request.InitialKwhPrice.Value,
EffectiveFrom = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
// ...
}
Cryptographic Key Generation
All secrets use RandomNumberGenerator (CSPRNG):
private string GenerateWebhookSecret()
{
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes); // 44-char Base64
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToHexStringLower(bytes); // 64-char hex
}
Cascade Delete
When a source is deleted, MongoDB collections are cleaned up in addition to the PostgreSQL cascade:
public async Task<bool> DeleteSunSourceAsync(Guid id, string userId)
{
_context.SunSources.Remove(sunSource);
await _context.SaveChangesAsync();
// Clean up all MongoDB data
await collection.DeleteManyAsync(d => d.SunSourceId == id); // normalized data
await _mongoDatabase.GetCollection<BsonDocument>("predictions").DeleteManyAsync(filter); // predictions
await _mongoDatabase.GetCollection<BsonDocument>("model_metadata").DeleteManyAsync(filter); // model metadata
await _mongoDatabase.GetCollection<BsonDocument>("weather_data").DeleteManyAsync(filter); // weather data
}
Connection Details Builder
Returns MQTT broker info or webhook URL based on the source's connection type:
private SunSourceConnectionDetailsDto BuildConnectionDetails(SunSource source)
{
if (source.ConnectionType == SunSourceConnectionType.MQTT)
{
return new SunSourceConnectionDetailsDto
{
MqttBrokerHost = _mqttSettings.Host,
MqttBrokerPort = _mqttSettings.Port,
MqttClientId = $"{MqttTopics.ClientIdPrefix}{source.Id}",
MqttApiKey = source.MqttApiKey,
MqttTopic = source.MqttTopic
};
}
return new SunSourceConnectionDetailsDto
{
WebhookUrl = source.WebhookUrl,
WebhookSecret = source.WebhookSecret
};
}
ISunSourceDataIngestionService
Implementation: SunSourceDataIngestionService · Lifetime: Scoped · Dependencies: IMongoDatabase, ApplicationDbContext
Handles data normalization and storage for both MQTT and Webhook ingestion paths.
Methods
| Method | Return Type | Description |
|---|---|---|
NormalizeJsonPayload(Guid sourceId, JsonElement root, string? rawPayload) | NormalizedSunSourceData | Normalize arbitrary JSON to canonical schema |
StoreNormalizedDataAsync(NormalizedSunSourceData) | Task | Insert into MongoDB |
ProcessWebhookPayloadAsync(Guid sourceId, string payload) | Task<(bool, string)> | Full webhook pipeline |
Field Alias Map
The normalizer maps 24 canonical field names to over 100 device-specific aliases:
private static readonly Dictionary<string, string[]> FieldAliases = new(StringComparer.OrdinalIgnoreCase)
{
["stateOfCharge"] = ["state_of_charge", "soc", "battery_soc", "batterysoc"],
["batteryVoltage"] = ["battery_voltage", "bat_voltage", "bat_v", "batt_voltage"],
["solarPower"] = ["solar_power", "pv_power", "pv_w", "panel_power", "mppt_power", "power_w"],
["loadPower"] = ["load_power", "load_w", "consumption", "consumption_w"],
["gridVoltage"] = ["grid_voltage", "mains_voltage", "utility_voltage"],
["operatingMode"] = ["operating_mode", "mode", "inverter_mode", "work_mode"],
["dailyEnergyProduced"] = ["daily_energy_produced", "daily_energy", "daily_kwh", "energy_today"],
// ... 17 more canonical fields
};
Value Range Validation
Every numeric field is validated against physical bounds before storage:
private static readonly Dictionary<string, (double Min, double Max)> ValueRanges = new()
{
["stateOfCharge"] = (0, 100),
["batteryVoltage"] = (0, 100),
["batteryCurrent"] = (-200, 200),
["batteryTemperature"] = (-40, 80),
["solarPower"] = (0, 100_000),
["gridVoltage"] = (0, 500),
["totalEnergyProduced"] = (0, 10_000_000),
// ... 16 more fields
};
Safe Double Extraction
Handles JSON numbers, string-encoded numbers, NaN/Infinity rejection, and range validation:
private static double? GetSafeDouble(Dictionary<string, JsonElement> props, string canonicalName)
{
var el = FindProperty(props, canonicalName);
if (!el.HasValue) return null;
double value;
if (el.Value.ValueKind == JsonValueKind.Number)
value = el.Value.GetDouble();
else if (el.Value.ValueKind == JsonValueKind.String &&
double.TryParse(el.Value.GetString(), NumberStyles.Float,
CultureInfo.InvariantCulture, out var parsed))
value = parsed;
else
return null;
if (double.IsNaN(value) || double.IsInfinity(value))
return null;
if (ValueRanges.TryGetValue(canonicalName, out var range) && (value < range.Min || value > range.Max))
return null;
return value;
}
Webhook Processing Pipeline
The full webhook flow includes authentication, throttling, payload sanitization, and storage:
public async Task<(bool Success, string Message)> ProcessWebhookPayloadAsync(Guid sunSourceId, string payload)
{
// 1. Validate source exists, is Webhook type, and is active
var sunSource = await _context.SunSources
.FirstOrDefaultAsync(ss => ss.Id == sunSourceId
&& ss.ConnectionType == SunSourceConnectionType.Webhook
&& ss.IsActive);
// 2. Parse JSON
var jsonDocument = JsonDocument.Parse(payload);
// 3. Constant-time secret comparison (prevents timing attacks)
var providedKey = root.GetProperty("key").GetString();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(providedKey),
Encoding.UTF8.GetBytes(sunSource.WebhookSecret ?? string.Empty)))
return (false, "Invalid webhook secret");
// 4. Throttle (10s minimum between messages per source)
if (_lastWebhookTimes.TryGetValue(sunSourceId, out var lastTime))
if (DateTime.UtcNow - lastTime < _messageThrottle)
return (true, "Throttled");
_lastWebhookTimes[sunSourceId] = DateTime.UtcNow;
// 5. Strip "key" field from stored data (never persist secrets)
// 6. Normalize and store
var normalizedData = NormalizeJsonPayload(sunSourceId, root, sanitizedPayload);
await StoreNormalizedDataAsync(normalizedData);
}
ISunSourceDataService
Implementation: SunSourceDataService · Lifetime: Scoped · Dependencies: IMongoDatabase, ISunSourceService, IWeatherService
MongoDB-backed data queries, aggregation, reporting, cleanup, downsampling, and debug data generation (approximately 700 lines).
Methods
| Method | Return Type | Description |
|---|---|---|
GetUserSunSourceStatsAsync(string userId) | Task<object> | Document counts and latest entry per source |
GetUserSunSourceStatusAsync(string userId) | Task<object> | Last message timestamps per source |
GetDataCountAsync(Guid sourceId, string userId) | Task<long> | Total document count |
GetDataSizeBytesAsync(Guid sourceId, string userId) | Task<long> | BSON size via $bsonSize aggregation |
CleanupDataAsync(Guid sourceId, string userId) | Task<long> | Delete all data for a source |
DownsampleDataAsync(Guid sourceId, string? userId) | Task<DownsampleResult> | Compact data older than 30 days |
GetReportDataAsync(Guid sourceId, string userId, string period, ...) | Task<object> | Comprehensive analytics report |
GenerateDebugDataAsync(Guid sourceId) | Task<int> | Generate 30 days of realistic simulation data |
Downsampling Algorithm
Data older than 30 days is compacted into 5-minute buckets to control storage growth:
public async Task<DownsampleResult> DownsampleDataAsync(Guid sourceId, string? userId = null)
{
var cutoff = DateTime.UtcNow.AddDays(-30);
// Group old data into 5-minute windows
// For each window:
// - Average: numeric fields (solarPower, loadPower, SOC, voltage, current, temperature)
// - Last value: cumulative fields (totalEnergy, dailyEnergy, operatingMode, deviceStatus)
// - Merge: faultCodes, warningCodes (union of all unique codes)
// Delete old documents, insert compact ones
// Returns: { before, after, message }
}
Report Data Aggregation
Generates comprehensive reports using MongoDB's aggregation framework:
// Summary stats: avg/max solarPower, loadPower, SOC, battery voltage, grid voltage
// Daily energy totals: $group by day → $max dailyEnergyProduced, $min/$max/$subtract
// Operating mode breakdown: count per mode
// Hourly data rollup: average values per hour
// Supports periods: "today", "yesterday", "week", or custom date range
ISunSourceMetricsService
Implementation: SunSourceMetricsService · Lifetime: Scoped · Dependencies: IMongoDatabase, ISunSourceService
Real-time and historical metrics queries with automatic aggregation for large date ranges.
Methods
| Method | Return Type | Description |
|---|---|---|
GetLatestMetricsAsync(Guid sourceId) | Task<SunSourceMetricsDto?> | Single latest data point |
GetMetricsHistoryAsync(Guid sourceId, DateTime start, DateTime end, int limit) | Task<IEnumerable<SunSourceMetricsDto>> | Raw time-series data (max 5000) |
GetAggregatedHistoricalDataAsync(Guid sourceId, DateTime start, DateTime end) | Task<IEnumerable<SunSourceHistoricalDataDto>> | Hourly aggregation (max 90 days) |
GetDailySummariesAsync(Guid sourceId, DateTime start, DateTime end) | Task<IEnumerable<DailySummaryDto>> | Daily summaries with auto-weekly rollup |
GetSourcesWithFaultsAsync(string userId) | Task<IEnumerable<SunSourceMetricsDto>> | Sources with active fault codes |
Auto-Weekly Aggregation
When the date range exceeds 60 days, daily summaries are automatically aggregated into weekly summaries:
public async Task<IEnumerable<DailySummaryDto>> GetDailySummariesAsync(
Guid sunSourceId, DateTime startTime, DateTime endTime)
{
// Query MongoDB for raw data in range
var dailySummaries = BuildDailySummaries(data);
// Auto-aggregate to weekly when range > 60 days
if ((endTime - startTime).TotalDays > 60)
return AggregateIntoWeeks(dailySummaries);
return dailySummaries;
}
private IEnumerable<DailySummaryDto> AggregateIntoWeeks(IEnumerable<DailySummaryDto> dailies)
{
// Group by ISO week
// Weighted average by sample count (preserves statistical accuracy)
// Sum energy totals, preserve min/max extremes
}
IPredictionService
Implementation: PredictionService · Lifetime: Scoped · Dependencies: HttpClient, ApplicationDbContext, IWeatherService, IConnectionMultiplexer (Redis), IMongoDatabase
Orchestrates ML training and prediction via the Python microservice with Redis-based job tracking.
Methods
| Method | Return Type | Description |
|---|---|---|
StartTrainingAsync(Guid sourceId, string userId) | Task<PredictionResultDto> | Queue ML training job |
StartPredictionAsync(Guid sourceId, string userId) | Task<PredictionResultDto> | Queue prediction job |
GetJobStatusAsync(string jobId, string userId) | Task<PredictionJobDto?> | Poll job status from Redis |
RunScheduledPredictionAsync(Guid sourceId) | Task | Fire-and-forget (used by Quartz) |
GetLatestPredictionAsync(Guid sourceId, string userId) | Task<object?> | Latest prediction from MongoDB |
GetLatestPredictionPublicAsync(Guid sourceId) | Task<object?> | Public endpoint (shared sources) |
GetModelInfoAsync(Guid sourceId, string userId) | Task<object?> | Model metadata from MongoDB |
Job Lifecycle
Jobs are tracked in Redis with a 24-hour TTL and a concurrent-execution guard:
public async Task<PredictionResultDto> StartTrainingAsync(Guid sunSourceId, string userId)
{
var db = _redis.GetDatabase();
// Prevent duplicate concurrent jobs
var activeKey = RedisKeys.MlActive(sunSourceId, "train"); // "ampra:ml:active:{sourceId}:train"
if (await db.KeyExistsAsync(activeKey))
return new PredictionResultDto { JobId = existingId, Status = "already_running" };
var jobId = Guid.NewGuid().ToString();
// Lock for 30 minutes max
await db.StringSetAsync(activeKey, jobId, TimeSpan.FromMinutes(30));
// Store initial status
await db.StringSetAsync(RedisKeys.MlJob(jobId), initialStatus, TimeSpan.FromHours(24));
// Fetch weather data for ML service
var weatherHistory = await BuildWeatherDataAsync(sunSourceId, sunSource);
// Fire-and-forget HTTP call to Python ML service
_ = Task.Run(async () =>
{
try
{
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(MlEndpoints.Train, content);
}
catch (Exception ex)
{
// Update Redis with failure status
await db.StringSetAsync(RedisKeys.MlJob(jobId), errorStatus, JobStatusTtl);
}
finally
{
await db.KeyDeleteAsync(activeKey); // Release lock
}
});
return new PredictionResultDto { JobId = jobId, Status = "queued" };
}
IRoiService
Implementation: RoiService · Lifetime: Scoped · Dependencies: ApplicationDbContext, IMongoDatabase, IOwnershipService
Calculates return on investment by matching energy production data against kWh price history.
Methods
| Method | Return Type | Description |
|---|---|---|
GetPriceHistoryAsync(Guid sourceId, string userId) | Task<IEnumerable<KwhPriceHistoryDto>> | All price entries ordered by date |
CreatePriceEntryAsync(Guid sourceId, CreateKwhPriceRequest, string userId) | Task<KwhPriceHistoryDto> | Add new price entry |
UpdatePriceEntryAsync(Guid priceId, UpdateKwhPriceRequest, string userId) | Task<KwhPriceHistoryDto?> | Update existing entry |
DeletePriceEntryAsync(Guid priceId, string userId) | Task<bool> | Delete price entry |
CalculateRoiAsync(Guid sourceId, string userId) | Task<RoiSummaryDto> | Full ROI calculation |
ROI Calculation Implementation
public async Task<RoiSummaryDto> CalculateRoiAsync(Guid sunSourceId, string userId)
{
// 1. Load price history (ordered by EffectiveFrom)
var prices = await _context.KwhPriceHistories
.Where(p => p.SunSourceId == sunSourceId)
.OrderBy(p => p.EffectiveFrom)
.ToListAsync();
// 2. MongoDB aggregation: daily MAX(dailyEnergyProduced)
var pipeline = new BsonDocument[]
{
new("$match", new BsonDocument("SunSourceId", sunSourceId)),
new("$group", new BsonDocument
{
{ "_id", new BsonDocument("$dateToString", new BsonDocument { { "format", "%Y-%m-%d" }, { "date", "$Timestamp" } }) },
{ "maxEnergy", new BsonDocument("$max", "$DailyEnergyProduced") }
}),
new("$sort", new BsonDocument("_id", 1))
};
// 3. Match prices to production days
foreach (var day in dailyProduction)
{
var applicablePrice = prices.LastOrDefault(p => p.EffectiveFrom <= day.Date);
savings += day.Energy * applicablePrice.PricePerKwh;
}
// 4. Compute ROI metrics (if SystemCost is set)
// ROI % = (totalSavings / systemCost) × 100
// Payback period = systemCost / (monthlyAvgSavings × 12)
}
IWeatherService
Implementation: WeatherService · Lifetime: Scoped (via AddHttpClient) · Dependencies: HttpClient, ApplicationDbContext, IOwnershipService
Fetches 8-day weather forecasts from the Open-Meteo API and stores them in PostgreSQL.
Methods
| Method | Return Type | Description |
|---|---|---|
GetWeatherDataAsync(Guid sourceId) | Task<List<WeatherData>> | All weather data for source |
GetWeatherDataForUserAsync(Guid sourceId, string userId) | Task<List<WeatherData>> | Future weather only (today+) |
RefreshWeatherDataAsync(SunSource source) | Task | Fetch from Open-Meteo and upsert |
RefreshWeatherDataForUserAsync(Guid sourceId, string userId) | Task | Refresh with ownership check |
Open-Meteo API Integration
public async Task RefreshWeatherDataAsync(SunSource sunSource)
{
var url = $"https://api.open-meteo.com/v1/forecast?" +
$"latitude={sunSource.Latitude}&longitude={sunSource.Longitude}" +
$"&daily=weather_code,temperature_2m_max,temperature_2m_min," +
$"shortwave_radiation_sum,uv_index_max,precipitation_sum,sunrise,sunset" +
$"&timezone=auto&forecast_days=8";
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var data = JsonSerializer.Deserialize<OpenMeteoResponse>(json);
// Upsert each day (unique constraint on SunSourceId + Date prevents duplicates)
for (int i = 0; i < data.Daily.Time.Count; i++)
{
var existing = await _context.WeatherData
.FirstOrDefaultAsync(w => w.SunSourceId == sunSource.Id && w.Date == date);
if (existing != null)
// Update existing record
else
// Create new record
}
await _context.SaveChangesAsync();
}
IOwnershipService
Implementation: OwnershipService · Lifetime: Scoped · Dependencies: ApplicationDbContext
Cross-cutting concern that validates source ownership before any data access.
Methods
| Method | Return Type | Description |
|---|---|---|
IsSourceOwnedByUserAsync(Guid sourceId, string userId) | Task<bool> | Check ownership |
ValidateSourceOwnershipAsync(Guid sourceId, string userId) | Task | Throws NotFoundException if not owned |
GetOwnedSourceOrThrowAsync(Guid sourceId, string userId) | Task<SunSource> | Returns source or throws |
public async Task ValidateSourceOwnershipAsync(Guid sunSourceId, string userId)
{
var exists = await _context.SunSources
.AnyAsync(ss => ss.Id == sunSourceId && ss.UserId == userId);
if (!exists)
throw new NotFoundException("Sun source not found");
}
IPowerGroupService
Implementation: PowerGroupService · Lifetime: Scoped · Dependencies: ApplicationDbContext
CRUD for organizational power groups with Mapster mapping.
Methods
| Method | Return Type | Description |
|---|---|---|
GetUserPowerGroupsAsync(string userId) | Task<IEnumerable<PowerGroupDto>> | List groups with source count |
GetPowerGroupByIdAsync(Guid id, string userId) | Task<PowerGroupDto?> | Get single group |
CreatePowerGroupAsync(CreatePowerGroupRequest, string userId) | Task<PowerGroupDto> | Create group |
UpdatePowerGroupAsync(Guid id, UpdatePowerGroupRequest, string userId) | Task<PowerGroupDto?> | Update group |
DeletePowerGroupAsync(Guid id, string userId) | Task<bool> | Delete (sources become ungrouped) |
ISharingService
Implementation: SharingService · Lifetime: Scoped · Dependencies: ApplicationDbContext
Manages public sharing settings for individual sun sources.
Methods
| Method | Return Type | Description |
|---|---|---|
GetSharingSettingsAsync(Guid sourceId, string userId) | Task<SunSourceSharingDto?> | Current sharing config |
UpdateSharingSettingsAsync(Guid sourceId, UpdateSharingRequest, string userId) | Task<SunSourceSharingDto> | Toggle sharing flags |
GetPublicSourceAsync(Guid sourceId) | Task<PublicSunSourceDto?> | Get publicly shared source data |
IEmailService
Implementation: SendGridEmailService · Lifetime: Scoped · Dependencies: IConfiguration
Sends transactional emails via the SendGrid API with embedded HTML templates.
Methods
| Method | Return Type | Description |
|---|---|---|
SendVerificationCodeAsync(string email, string code, string firstName) | Task | 6-digit code email |
SendWelcomeEmailAsync(string email, string firstName) | Task | Post-verification welcome |
IStorageService
Implementation: MinIOStorageService · Lifetime: Scoped · Dependencies: IOptions<MinIOSettings>
S3-compatible object storage via MinIO for ML model artefacts.
Methods
| Method | Return Type | Description |
|---|---|---|
UploadFileAsync(string bucket, string key, Stream data, string contentType) | Task | Upload file |
DeleteFileAsync(string bucket, string key) | Task | Delete file |
GetPresignedUrlAsync(string bucket, string key, int expiry) | Task<string> | Generate temporary download URL |
IMqttAuthService
Implementation: MqttAuthService · Lifetime: Scoped · Dependencies: ApplicationDbContext
Called by the EMQX broker via HTTP callbacks for client authentication and ACL validation.
Methods
| Method | Return Type | Description |
|---|---|---|
AuthenticateClientAsync(MqttAuthRequest) | Task<bool> | Validate clientId + API key against PostgreSQL |
AuthorizePublishAsync(MqttAclRequest) | Task<bool> | Verify topic matches source ownership |
public async Task<bool> AuthenticateClientAsync(MqttAuthRequest request)
{
// Extract sourceId from clientId ("ampra-source-{guid}")
// Look up source in PostgreSQL
// Validate: IsActive, ConnectionType == MQTT, MqttApiKey matches
return isValid;
}
IExportService
Implementation: ExportService · Lifetime: Scoped · Dependencies: IMongoDatabase, IOwnershipService
Exports telemetry data to CSV or Excel (XLSX) format using ClosedXML.
Methods
| Method | Return Type | Description |
|---|---|---|
ExportToCsvAsync(Guid sourceId, string userId, DateTime start, DateTime end) | Task<byte[]> | CSV export |
ExportToExcelAsync(Guid sourceId, string userId, DateTime start, DateTime end) | Task<byte[]> | XLSX with summary sheet |
IUserSettingService
Implementation: UserSettingService · Lifetime: Scoped · Dependencies: ApplicationDbContext
Simple get/upsert for user settings (auto-weather, auto-predictions, temperature unit).
Methods
| Method | Return Type | Description |
|---|---|---|
GetSettingAsync(string userId, int settingType) | Task<UserSetting?> | Get setting value |
UpsertSettingAsync(string userId, int settingType, string value) | Task<UserSetting> | Create or update |
public async Task<UserSetting> UpsertSettingAsync(string userId, int settingType, string value)
{
var existing = await _context.UserSettings
.FirstOrDefaultAsync(s => s.UserId == userId && s.SettingType == settingType);
if (existing != null)
{
existing.Value = value;
existing.UpdatedAt = DateTime.UtcNow;
}
else
{
_context.UserSettings.Add(new UserSetting
{
UserId = userId, SettingType = settingType,
Value = value, CreatedAt = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
return existing ?? /* new entry */;
}
IDebugProcessService
Implementation: DebugProcessService · Lifetime: Singleton (BackgroundService) · Dependencies: IServiceScopeFactory
Generates realistic solar telemetry data in real-time (every 5 seconds) for testing and demonstration purposes. Runs as a hosted background service.
Methods
| Method | Return Type | Description |
|---|---|---|
StartProcess(Guid sourceId) | bool | Start generating data for a source |
StopProcess(Guid sourceId) | bool | Stop generation |
IsRunning(Guid sourceId) | bool | Check if generator is active |
GetSentCount(Guid sourceId) | long | Total messages sent for source |
The debug generator simulates:
- Solar production based on time of day, season, and cloud cover patterns
- Battery SOC with charge/discharge cycles, voltage curves, and temperature effects
- Load profiles with morning/evening peaks, weekend patterns, and random appliance spikes
- Grid behaviour including random outages (0–5 per month, 10–190 minutes each)
- Operating modes (Solar, Hybrid, Battery, Grid, Off-Grid variants)
- Device statuses (Normal, Low Battery, High Temp Warning, High Load)