================================================================================
BrightMeasurementsController - Summary & Response Structures
File: SonWinCommonAPI/Controllers/BrightMeasurementsController.cs
================================================================================
--------------------------------------------------------------------------------
WHAT THE CONTROLLER DOES
--------------------------------------------------------------------------------
BrightMeasurementsController exposes a customer's energy consumption readings
for a single service (metering point) over a date range, aggregated to a chosen
time resolution, shaped into the "Bright" measurement contract.
Any thrown exception, goes through logs the error (internal trace file + SonWin BLOG)
and returns HTTP 500 with the exception message in the body.
The controller has FOUR endpoints (monthly, daily, hourly, 15-minute). They are
identical except for ONE thing: each one calls the SAME service method but passes a different.MeasurementsResolution value:
- GET /bright/measurements -> MeasurementsResolution.Monthly ("month")
- GET /bright/measurementDays -> MeasurementsResolution.Daily ("day")
- GET /bright/measurementHours -> MeasurementsResolution.Hourly ("hour")
- GET /bright/measurements15min -> MeasurementsResolution.Quarterly ("15min")
The resolution decides ONLY how the raw 15-minute readings are bucketed/summed
before being returned; every endpoint returns the SAME BrightMeasurementReading
shape.
IMPORTANT - HOW AGGREGATION WORKS:
The repository ALWAYS reads quarter-hour (15-minute) rows from the database. The service then:
- Fills in any missing 15-minute slot in [DateFrom, DateTo) with KWh = 0,so the series is gap-free.
- For Quarterly: returns the 15-minute series as-is.
- For Hourly: sums the 15-minute rows into hour buckets.
- For Daily: sums into LOCAL-DAY buckets (Europe/Copenhagen, DST-aware).
- For Monthly: sums into LOCAL-MONTH buckets (Europe/Copenhagen, DST-aware).
Daily/Monthly bucket boundaries are computed in Europe/Copenhagen local time but the timestamps on each returned bucket are still emitted in UTC (the first reading of the bucket keeps its original UTC Date).
--------------------------------------------------------------------------------
METHODS
--------------------------------------------------------------------------------
All four methods share the same parameters, validation, error handling and
response type. They differ only in route and the resolution passed to the
service (see WHAT THE CONTROLLER DOES). Common signature:
Params:
- CustomerID (required)
- ServiceID (required)
- DateFrom (required - utc)
- DateTo (required - utc)
- (CancellationToken ct)
All passed via [FromQuery].
Returns:
- 200 OK -> BrightMeasurementReading
- 400 BadRequest (declared; see note below)
- 401 Unauthorized
Methods:
1) GetMeasurements
Route: GET /bright/measurements
Resolution passed: MeasurementsResolution.Monthly -> Resolution = "month"
What it does:
Returns the customer's readings for the service, summed into local-month
buckets across [DateFrom, DateTo).
2) GetMeasurementDays
Route: GET /bright/measurementDays
Resolution passed: MeasurementsResolution.Daily -> Resolution = "day"
What it does:
Returns the readings summed into local-day buckets.
3) GetMeasurementHours
Route: GET /bright/measurementHours
Resolution passed: MeasurementsResolution.Hourly -> Resolution = "hour"
What it does:
Returns the readings summed into hour buckets.
4) GetMeasurements15min
Route: GET /bright/measurements15min
Resolution passed: MeasurementsResolution.Quarterly -> Resolution = "15min"
What it does:
Returns the raw 15-minute readings (gap-filled), no further aggregation.
Validation that affects the result (ALL surface as HTTP 500 with a message;
each message is wrapped as "The operation [GetMeasurements] could not be
completed due to the following validation errors: ..."):
- Customer does not exist → (looks the customer up via account search).
- - Missing CustomerID -> "Missing parameter 'CustomerId'."
- - Missing ServiceID -> "Missing parameter 'ServiceId'."
- - Missing DateFrom -> "Missing parameter 'DateFrom'." (a DateTime equal to default/01-01-0001 counts as missing)
- - Missing DateTo -> "Missing parameter 'DateTo'."
- - DateFrom >= DateTo -> "'DateFrom' must be before 'DateTo'."
- - A reading missing a date -> "Reading at index {i} is missing a date value." (data-integrity guard)
- - Reading for inactive customer -> "Readings found for inactive customer"
- - Reading for inactive install. -> "Readings found for inactive installation"
- - Metering point mismatch -> "Wrong data. Meteringpoint not matching found in collection from database"
- - DateFrom/DateTo Kind = Local -> "DateTime is in an incorect KIND..." :
(thrown by DateTimeHelper when building
the response StartDate/EndDate; model
binding normally yields Unspecified kind,
which is allowed.)
--------------------------------------------------------------------------------
RESPONSE STRUCTURE (BrightMeasurementReading)
--------------------------------------------------------------------------------
Returned as a single JSON OBJECT (BrightMeasurementReading), not an array.
{
"StartDate": <value> // Derived: the request DateFrom converted to a UTC DateTimeOffset
// (DateTimeHelper.DateTimeToDateTimeOffset_UTC). Echoes the input range start,
// NOT the first reading. Throws if DateFrom.Kind == Local.
"EndDate": <value> // Derived: the request DateTo converted to a UTC DateTimeOffset.
// Echoes the input range end, NOT the last reading. Throws if Kind == Local.
"Resolution": <value> // Derived: the resolution enum's DISPLAY name (GetDisplayName):
// "month" / "day" / "hour" / "15min" depending on the endpoint.
// (Uses the [Display(Name=...)] value, not the enum name.)
"ComparisonProperties": [] // Hardcoded: never populated by the mapper. Always an empty array.
// (BrightComparisonProperty.Key/Unit are unused today.)
"Values": [ // Data/Derived: one element per bucket at the chosen resolution, ordered by date.
// Gap-filled: every slot in the range is present (missing readings -> KWh 0).
{
"Date": <value> // Data: bucket timestamp (TimeSeriesValues.DATOTID), as a UTC DateTimeOffset.
// The DB stores UTC; Dapper's local offset is stripped and re-stamped to +00:00.
// For aggregated buckets this is the timestamp of the FIRST reading in the bucket.
"KWh": <value> // Data/Derived: kilowatt-hours (TimeSeriesValues.KWH). For Quarterly it is the raw
// 15-min value (0 if the slot was missing); for Hour/Day/Month it is the SUM of the
// 15-min KWh values in that bucket. Null KWh from the DB is treated as 0.
"M3": null // Hardcoded null: water volume. Commented out in the mapper ("r.M3") - not implemented.
"Kg": null // Hardcoded null: mass. Commented out in the mapper ("r.Kg") - not implemented.
"Cost": null // Hardcoded null: cost. Commented out in the mapper ("r.Cost") - not implemented.
"PeakWatt": <value|null> // Derived: an object is created ONLY if the DB row's PeakWattValue is not null;
// otherwise null. In practice the current SQL never selects PeakWattValue, so this
// is effectively ALWAYS null today.
// When present, the object's fields are:
{ // (only emitted when PeakWatt is non-null)
"Value": null // Hardcoded null: commented out in the mapper ("r.PeakWattValue") - not implemented.
"Cost": null // Hardcoded null: commented out in the mapper ("r.PeakWattCost") - not implemented.
"Peaks": [] // Hardcoded: never populated ("TODO: map Peaks once DB schema is known"). Empty array.
}
"Parts": [] // Hardcoded: never populated ("TODO: map Parts ... once DB schema is known"). Empty array.
"Compare": [] // Hardcoded: never populated ("TODO: map ... Compare once DB schema is known"). Empty array.
}
]
}