Skip to main content

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

MethodReturn TypeDescription
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

MethodReturn TypeDescription
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

MethodReturn TypeDescription
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

MethodReturn TypeDescription
NormalizeJsonPayload(Guid sourceId, JsonElement root, string? rawPayload)NormalizedSunSourceDataNormalize arbitrary JSON to canonical schema
StoreNormalizedDataAsync(NormalizedSunSourceData)TaskInsert 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

MethodReturn TypeDescription
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

MethodReturn TypeDescription
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

MethodReturn TypeDescription
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)TaskFire-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

MethodReturn TypeDescription
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

MethodReturn TypeDescription
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)TaskFetch from Open-Meteo and upsert
RefreshWeatherDataForUserAsync(Guid sourceId, string userId)TaskRefresh 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

MethodReturn TypeDescription
IsSourceOwnedByUserAsync(Guid sourceId, string userId)Task<bool>Check ownership
ValidateSourceOwnershipAsync(Guid sourceId, string userId)TaskThrows 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

MethodReturn TypeDescription
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

MethodReturn TypeDescription
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

MethodReturn TypeDescription
SendVerificationCodeAsync(string email, string code, string firstName)Task6-digit code email
SendWelcomeEmailAsync(string email, string firstName)TaskPost-verification welcome

IStorageService

Implementation: MinIOStorageService · Lifetime: Scoped · Dependencies: IOptions<MinIOSettings>

S3-compatible object storage via MinIO for ML model artefacts.

Methods

MethodReturn TypeDescription
UploadFileAsync(string bucket, string key, Stream data, string contentType)TaskUpload file
DeleteFileAsync(string bucket, string key)TaskDelete 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

MethodReturn TypeDescription
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

MethodReturn TypeDescription
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

MethodReturn TypeDescription
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

MethodReturn TypeDescription
StartProcess(Guid sourceId)boolStart generating data for a source
StopProcess(Guid sourceId)boolStop generation
IsRunning(Guid sourceId)boolCheck if generator is active
GetSentCount(Guid sourceId)longTotal 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)