-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathapi_dataclasses.py
More file actions
1026 lines (927 loc) · 38.8 KB
/
Copy pathapi_dataclasses.py
File metadata and controls
1026 lines (927 loc) · 38.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""API DataClasses with canonical camelCase field names."""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from datetime import datetime
from pydantic import BaseModel, field_validator
from core.bess import time_utils
logger = logging.getLogger(__name__)
@dataclass
class FormattedValue:
"""Formatted value structure for frontend display."""
value: float
display: str
unit: str
text: str
def create_formatted_value(
value: float, unit_type: str, currency: str, precision: int | None = None
) -> FormattedValue:
"""Create FormattedValue with currency parameter.
Args:
value: The numeric value to format
unit_type: Type of unit ("currency", "energy_kwh_only", "percentage", "price", etc.)
currency: Currency code (e.g. EUR, GBP, SEK, NOK, USD)
precision: Override default decimal places (None = use defaults: currency=2, energy=2, percentage=1, price=2)
"""
if unit_type == "currency":
prec = precision if precision is not None else 2
return FormattedValue(
value=value,
display=f"{value:,.{prec}f}",
unit=currency,
text=f"{value:,.{prec}f} {currency}",
)
elif unit_type == "energy_kwh_only":
# Always use kWh units to ensure consistency in savings view
# Small values like 0.2 kWh should remain as "0.2 kWh", not "200 Wh"
prec = precision if precision is not None else 1
return FormattedValue(
value=value,
display=f"{value:.{prec}f}",
unit="kWh",
text=f"{value:.{prec}f} kWh",
)
elif unit_type == "percentage":
prec = precision if precision is not None else 0
return FormattedValue(
value=value,
display=f"{value:.{prec}f}",
unit="%",
text=f"{value:.{prec}f} %",
)
elif unit_type == "price":
prec = precision if precision is not None else 2
price_unit = f"{currency}/kWh"
return FormattedValue(
value=value,
display=f"{value:.{prec}f}",
unit=price_unit,
text=f"{value:.{prec}f} {price_unit}",
)
else:
# Default fallback
return FormattedValue(
value=value, display=f"{value:.2f}", unit="", text=f"{value:.2f}"
)
@dataclass
class APIPredictionSnapshot:
"""API representation of PredictionSnapshot."""
snapshotTimestamp: str # ISO format
optimizationPeriod: int
predictedDailySavings: FormattedValue
totalExpectedSavings: FormattedValue # Actuals + predicted remainder
periodCount: int # From daily_view
actualCount: int # From daily_view
growattScheduleCount: int # Number of TOU intervals
@classmethod
def from_internal(cls, snapshot, currency: str) -> APIPredictionSnapshot:
"""Convert from internal PredictionSnapshot to API format.
Args:
snapshot: PredictionSnapshot object
currency: Currency code for formatting
Returns:
APIPredictionSnapshot with camelCase fields
"""
# Compute total savings same way as dashboard: grid_only_cost - hourly_cost
total_savings = sum(
p.economic.grid_only_cost - p.economic.hourly_cost
for p in snapshot.daily_view.periods
if p.economic is not None
)
return cls(
snapshotTimestamp=snapshot.snapshot_timestamp.isoformat(),
optimizationPeriod=snapshot.optimization_period,
predictedDailySavings=create_formatted_value(
snapshot.predicted_daily_savings, "currency", currency
),
totalExpectedSavings=create_formatted_value(
total_savings, "currency", currency
),
periodCount=len(snapshot.daily_view.periods),
actualCount=snapshot.daily_view.actual_count,
growattScheduleCount=len(snapshot.growatt_schedule),
)
@dataclass
class APIPeriodDeviation:
"""API representation of period-level deviation."""
period: int
predictedBatteryAction: FormattedValue
actualBatteryAction: FormattedValue
batteryActionDeviation: FormattedValue
predictedConsumption: FormattedValue
actualConsumption: FormattedValue
consumptionDeviation: FormattedValue
predictedSolar: FormattedValue
actualSolar: FormattedValue
solarDeviation: FormattedValue
predictedGridImport: FormattedValue
actualGridImport: FormattedValue
gridImportDeviation: FormattedValue
predictedGridExport: FormattedValue
actualGridExport: FormattedValue
gridExportDeviation: FormattedValue
predictedSavings: FormattedValue
actualSavings: FormattedValue
savingsDeviation: FormattedValue
deviationType: str
@classmethod
def from_internal(cls, period_deviation, currency: str) -> APIPeriodDeviation:
"""Convert from internal PeriodDeviation to API format.
Args:
period_deviation: PeriodDeviation object
currency: Currency code for formatting
Returns:
APIPeriodDeviation with camelCase fields
"""
return cls(
period=period_deviation.period,
predictedBatteryAction=create_formatted_value(
period_deviation.predicted_battery_action, "energy_kwh_only", currency
),
actualBatteryAction=create_formatted_value(
period_deviation.actual_battery_action, "energy_kwh_only", currency
),
batteryActionDeviation=create_formatted_value(
period_deviation.battery_action_deviation, "energy_kwh_only", currency
),
predictedConsumption=create_formatted_value(
period_deviation.predicted_consumption, "energy_kwh_only", currency
),
actualConsumption=create_formatted_value(
period_deviation.actual_consumption, "energy_kwh_only", currency
),
consumptionDeviation=create_formatted_value(
period_deviation.consumption_deviation, "energy_kwh_only", currency
),
predictedSolar=create_formatted_value(
period_deviation.predicted_solar, "energy_kwh_only", currency
),
actualSolar=create_formatted_value(
period_deviation.actual_solar, "energy_kwh_only", currency
),
solarDeviation=create_formatted_value(
period_deviation.solar_deviation, "energy_kwh_only", currency
),
predictedGridImport=create_formatted_value(
period_deviation.predicted_grid_import, "energy_kwh_only", currency
),
actualGridImport=create_formatted_value(
period_deviation.actual_grid_import, "energy_kwh_only", currency
),
gridImportDeviation=create_formatted_value(
period_deviation.grid_import_deviation, "energy_kwh_only", currency
),
predictedGridExport=create_formatted_value(
period_deviation.predicted_grid_export, "energy_kwh_only", currency
),
actualGridExport=create_formatted_value(
period_deviation.actual_grid_export, "energy_kwh_only", currency
),
gridExportDeviation=create_formatted_value(
period_deviation.grid_export_deviation, "energy_kwh_only", currency
),
predictedSavings=create_formatted_value(
period_deviation.predicted_savings, "currency", currency
),
actualSavings=create_formatted_value(
period_deviation.actual_savings, "currency", currency
),
savingsDeviation=create_formatted_value(
period_deviation.savings_deviation, "currency", currency
),
deviationType=period_deviation.deviation_type,
)
@dataclass
class APISnapshotComparison:
"""API representation of snapshot comparison."""
snapshotTimestamp: str
snapshotPeriod: int
comparisonTime: str
periodDeviations: list[dict] # List of APIPeriodDeviation as dicts
totalPredictedSavings: FormattedValue
totalActualSavings: FormattedValue
savingsDeviation: FormattedValue
primaryDeviationCause: str
# Full-day savings breakdown at snapshot time (actuals + predicted = total)
snapshotTotalSavings: FormattedValue
snapshotActualSavings: FormattedValue
snapshotPredictedSavings: FormattedValue
# Full-day savings breakdown now (actuals + predicted = total)
currentTotalSavings: FormattedValue
currentActualSavings: FormattedValue
currentPredictedSavings: FormattedValue
predictedGrowattSchedule: list[dict] # TOU intervals from snapshot
currentGrowattSchedule: list[dict] # Current TOU intervals
@classmethod
def from_internal(cls, snapshot_comparison, currency: str) -> APISnapshotComparison:
"""Convert from internal SnapshotComparison to API format.
Args:
snapshot_comparison: SnapshotComparison object
currency: Currency code for formatting
Returns:
APISnapshotComparison with camelCase fields
"""
return cls(
snapshotTimestamp=snapshot_comparison.reference_snapshot.snapshot_timestamp.isoformat(),
snapshotPeriod=snapshot_comparison.reference_snapshot.optimization_period,
comparisonTime=datetime.now().isoformat(),
periodDeviations=[
APIPeriodDeviation.from_internal(dev, currency).__dict__
for dev in snapshot_comparison.period_deviations
],
totalPredictedSavings=create_formatted_value(
snapshot_comparison.total_predicted_savings, "currency", currency
),
totalActualSavings=create_formatted_value(
snapshot_comparison.total_actual_savings, "currency", currency
),
savingsDeviation=create_formatted_value(
snapshot_comparison.savings_deviation, "currency", currency
),
primaryDeviationCause=snapshot_comparison.primary_deviation_cause,
snapshotTotalSavings=create_formatted_value(
snapshot_comparison.snapshot_total_savings, "currency", currency
),
snapshotActualSavings=create_formatted_value(
snapshot_comparison.snapshot_actual_savings, "currency", currency
),
snapshotPredictedSavings=create_formatted_value(
snapshot_comparison.snapshot_predicted_savings, "currency", currency
),
currentTotalSavings=create_formatted_value(
snapshot_comparison.current_total_savings, "currency", currency
),
currentActualSavings=create_formatted_value(
snapshot_comparison.current_actual_savings, "currency", currency
),
currentPredictedSavings=create_formatted_value(
snapshot_comparison.current_predicted_savings, "currency", currency
),
predictedGrowattSchedule=snapshot_comparison.predicted_growatt_schedule,
currentGrowattSchedule=snapshot_comparison.current_growatt_schedule,
)
@dataclass
class APIDashboardHourlyData:
"""Dashboard hourly data with canonical FormattedValue interface."""
# Metadata
period: int
dataSource: str
timestamp: str | None
# All user-facing data via FormattedValue - canonical naming
solarProduction: FormattedValue
homeConsumption: FormattedValue
batterySocStart: FormattedValue
batterySocEnd: FormattedValue
batterySoeStart: FormattedValue
batterySoeEnd: FormattedValue
buyPrice: FormattedValue
sellPrice: FormattedValue
hourlyCost: FormattedValue
hourlySavings: FormattedValue
gridOnlyCost: FormattedValue
solarOnlyCost: FormattedValue
batteryAction: FormattedValue
batteryCharged: FormattedValue
batteryDischarged: FormattedValue
gridImported: FormattedValue
gridExported: FormattedValue
# Detailed energy flows - automatically calculated in backend models
solarToHome: FormattedValue
solarToBattery: FormattedValue
solarToGrid: FormattedValue
gridToHome: FormattedValue
gridToBattery: FormattedValue
batteryToHome: FormattedValue
batteryToGrid: FormattedValue
# Solar-only scenario fields
gridImportNeeded: (
FormattedValue # How much grid import needed in solar-only scenario
)
solarExcess: FormattedValue # How much solar excess in solar-only scenario
solarSavings: FormattedValue # Savings from solar vs grid-only
# Raw values for logic only
strategicIntent: str
observedIntent: str | None
directSolar: float
@classmethod
def from_internal(
cls, hourly, battery_capacity: float, currency: str
) -> APIDashboardHourlyData:
"""Convert internal HourlyData to API format using pure dataclass approach."""
def safe_format(value, unit_type):
"""Helper to safely format values using pure dataclass approach"""
return create_formatted_value(value or 0, unit_type, currency)
# Calculate derived values
solar_production = hourly.energy.solar_production
home_consumption = hourly.energy.home_consumption
direct_solar = min(solar_production, home_consumption)
# Period index (0-23 for hourly, 0-95 for quarterly)
# Frontend correctly handles different resolutions via resolution parameter
return cls(
# Metadata
period=hourly.period,
dataSource="actual" if hourly.data_source == "actual" else "predicted",
timestamp=hourly.timestamp.isoformat() if hourly.timestamp else None,
# Energy flows
solarProduction=safe_format(solar_production, "energy_kwh_only"),
homeConsumption=safe_format(home_consumption, "energy_kwh_only"),
# Battery state - EnergyData uses battery_soe (State of Energy in kWh)
batterySocStart=safe_format(
(hourly.energy.battery_soe_start / battery_capacity) * 100.0,
"percentage",
),
batterySocEnd=safe_format(
(hourly.energy.battery_soe_end / battery_capacity) * 100.0,
"percentage",
),
batterySoeStart=safe_format(
hourly.energy.battery_soe_start,
"energy_kwh_only",
),
batterySoeEnd=safe_format(
hourly.energy.battery_soe_end,
"energy_kwh_only",
),
# Economic data
buyPrice=safe_format(hourly.economic.buy_price, "price"),
sellPrice=safe_format(hourly.economic.sell_price, "price"),
hourlyCost=safe_format(hourly.economic.hourly_cost, "currency"),
hourlySavings=safe_format(hourly.economic.hourly_savings, "currency"),
gridOnlyCost=safe_format(hourly.economic.grid_only_cost, "currency"),
solarOnlyCost=safe_format(hourly.economic.solar_only_cost, "currency"),
# Battery control - use actual charge/discharge for historical data
batteryAction=safe_format(
(
# For historical data, calculate from actual charge/discharge
(hourly.energy.battery_charged - hourly.energy.battery_discharged)
if hourly.data_source == "actual"
# For predicted data, use the optimization decision
else (hourly.decision.battery_action or 0)
),
"energy_kwh_only",
),
batteryCharged=safe_format(
hourly.energy.battery_charged,
"energy_kwh_only",
),
batteryDischarged=safe_format(
hourly.energy.battery_discharged,
"energy_kwh_only",
),
# Grid interactions
gridImported=safe_format(
hourly.energy.grid_imported,
"energy_kwh_only",
),
gridExported=safe_format(
hourly.energy.grid_exported,
"energy_kwh_only",
),
# Detailed energy flows - using existing calculated fields from backend models
solarToHome=safe_format(
hourly.energy.solar_to_home,
"energy_kwh_only",
),
solarToBattery=safe_format(
hourly.energy.solar_to_battery,
"energy_kwh_only",
),
solarToGrid=safe_format(
hourly.energy.solar_to_grid,
"energy_kwh_only",
),
gridToHome=safe_format(
hourly.energy.grid_to_home,
"energy_kwh_only",
),
gridToBattery=safe_format(
hourly.energy.grid_to_battery,
"energy_kwh_only",
),
batteryToHome=safe_format(
hourly.energy.battery_to_home,
"energy_kwh_only",
),
batteryToGrid=safe_format(
hourly.energy.battery_to_grid,
"energy_kwh_only",
),
# Solar-only scenario calculations
gridImportNeeded=safe_format(
max(0, home_consumption - solar_production),
"energy_kwh_only",
),
solarExcess=safe_format(
max(0, solar_production - home_consumption),
"energy_kwh_only",
),
solarSavings=safe_format(
hourly.economic.solar_savings,
"currency",
),
# Raw values for logic
strategicIntent=hourly.decision.strategic_intent,
observedIntent=hourly.decision.observed_intent,
directSolar=direct_solar,
)
@dataclass
class APICostAndSavings:
"""Cost and savings data for SystemStatusCard component."""
todaysCost: FormattedValue
todaysSavings: FormattedValue
gridOnlyCost: FormattedValue
percentageSaved: FormattedValue
@dataclass
class APIDashboardSummary:
"""Dashboard summary with canonical FormattedValue interface."""
# Cost scenarios
gridOnlyCost: FormattedValue
solarOnlyCost: FormattedValue
optimizedCost: FormattedValue
# Savings calculations
totalSavings: FormattedValue
solarSavings: FormattedValue
batterySavings: FormattedValue
# Energy totals
totalSolarProduction: FormattedValue
totalHomeConsumption: FormattedValue
totalBatteryCharged: FormattedValue
totalBatteryDischarged: FormattedValue
totalGridImported: FormattedValue
totalGridExported: FormattedValue
# Detailed energy flows
totalSolarToHome: FormattedValue
totalSolarToBattery: FormattedValue
totalSolarToGrid: FormattedValue
totalGridToHome: FormattedValue
totalGridToBattery: FormattedValue
totalBatteryToHome: FormattedValue
totalBatteryToGrid: FormattedValue
# Percentages
totalSavingsPercentage: FormattedValue
solarSavingsPercentage: FormattedValue
batterySavingsPercentage: FormattedValue
gridToHomePercentage: FormattedValue
gridToBatteryPercentage: FormattedValue
solarToGridPercentage: FormattedValue
batteryToGridPercentage: FormattedValue
solarToBatteryPercentage: FormattedValue
gridToBatteryChargedPercentage: FormattedValue
batteryToHomePercentage: FormattedValue
batteryToGridDischargedPercentage: FormattedValue
selfConsumptionPercentage: FormattedValue
# Efficiency metrics
cycleCount: FormattedValue
netBatteryAction: FormattedValue
averagePrice: FormattedValue
finalBatterySoe: FormattedValue
@classmethod
def from_totals(
cls, totals: dict, costs: dict, battery_capacity: float, currency: str
) -> APIDashboardSummary:
"""Create summary from totals and cost calculations."""
# Extract cost values
total_grid_only_cost = costs["gridOnly"]
total_solar_only_cost = costs["solarOnly"]
total_optimized_cost = costs["optimized"]
# Calculate savings
solar_savings = total_grid_only_cost - total_solar_only_cost
battery_savings = total_solar_only_cost - total_optimized_cost
total_savings = total_grid_only_cost - total_optimized_cost
def safe_percentage(numerator: float, denominator: float) -> float:
"""Safely calculate percentage"""
return (numerator / denominator * 100) if denominator > 0 else 0
return cls(
# Cost scenarios
gridOnlyCost=create_formatted_value(
total_grid_only_cost, "currency", currency
),
solarOnlyCost=create_formatted_value(
total_solar_only_cost, "currency", currency
),
optimizedCost=create_formatted_value(
total_optimized_cost, "currency", currency
),
# Savings calculations
totalSavings=create_formatted_value(total_savings, "currency", currency),
solarSavings=create_formatted_value(solar_savings, "currency", currency),
batterySavings=create_formatted_value(
battery_savings, "currency", currency
),
# Energy totals
totalSolarProduction=create_formatted_value(
totals["totalSolarProduction"], "energy_kwh_only", currency
),
totalHomeConsumption=create_formatted_value(
totals["totalHomeConsumption"], "energy_kwh_only", currency
),
totalBatteryCharged=create_formatted_value(
totals["totalBatteryCharged"], "energy_kwh_only", currency
),
totalBatteryDischarged=create_formatted_value(
totals["totalBatteryDischarged"], "energy_kwh_only", currency
),
totalGridImported=create_formatted_value(
totals["totalGridImport"], "energy_kwh_only", currency
),
totalGridExported=create_formatted_value(
totals["totalGridExport"], "energy_kwh_only", currency
),
# Detailed energy flows
totalSolarToHome=create_formatted_value(
totals["totalSolarToHome"], "energy_kwh_only", currency
),
totalSolarToBattery=create_formatted_value(
totals["totalSolarToBattery"], "energy_kwh_only", currency
),
totalSolarToGrid=create_formatted_value(
totals["totalSolarToGrid"], "energy_kwh_only", currency
),
totalGridToHome=create_formatted_value(
totals["totalGridToHome"], "energy_kwh_only", currency
),
totalGridToBattery=create_formatted_value(
totals["totalGridToBattery"], "energy_kwh_only", currency
),
totalBatteryToHome=create_formatted_value(
totals["totalBatteryToHome"], "energy_kwh_only", currency
),
totalBatteryToGrid=create_formatted_value(
totals["totalBatteryToGrid"], "energy_kwh_only", currency
),
# Percentages
totalSavingsPercentage=create_formatted_value(
safe_percentage(total_savings, total_grid_only_cost),
"percentage",
currency,
),
solarSavingsPercentage=create_formatted_value(
safe_percentage(solar_savings, total_grid_only_cost),
"percentage",
currency,
),
batterySavingsPercentage=create_formatted_value(
safe_percentage(battery_savings, total_solar_only_cost),
"percentage",
currency,
),
gridToHomePercentage=create_formatted_value(
safe_percentage(totals["totalGridToHome"], totals["totalGridImport"]),
"percentage",
currency,
),
gridToBatteryPercentage=create_formatted_value(
safe_percentage(
totals["totalGridToBattery"], totals["totalGridImport"]
),
"percentage",
currency,
),
solarToGridPercentage=create_formatted_value(
safe_percentage(totals["totalSolarToGrid"], totals["totalGridExport"]),
"percentage",
currency,
),
batteryToGridPercentage=create_formatted_value(
safe_percentage(
totals["totalBatteryToGrid"], totals["totalGridExport"]
),
"percentage",
currency,
),
solarToBatteryPercentage=create_formatted_value(
safe_percentage(
totals["totalSolarToBattery"], totals["totalBatteryCharged"]
),
"percentage",
currency,
),
gridToBatteryChargedPercentage=create_formatted_value(
safe_percentage(
totals["totalGridToBattery"], totals["totalBatteryCharged"]
),
"percentage",
currency,
),
batteryToHomePercentage=create_formatted_value(
safe_percentage(
totals["totalBatteryToHome"], totals["totalBatteryDischarged"]
),
"percentage",
currency,
),
batteryToGridDischargedPercentage=create_formatted_value(
safe_percentage(
totals["totalBatteryToGrid"], totals["totalBatteryDischarged"]
),
"percentage",
currency,
),
selfConsumptionPercentage=create_formatted_value(
safe_percentage(
totals["totalSolarProduction"], totals["totalHomeConsumption"]
),
"percentage",
currency,
),
# Efficiency metrics
cycleCount=create_formatted_value(
(
totals["totalBatteryCharged"] / battery_capacity
if battery_capacity > 0
else 0.0
),
"",
currency,
),
netBatteryAction=create_formatted_value(
totals["totalBatteryCharged"] - totals["totalBatteryDischarged"],
"energy_kwh_only",
currency,
),
averagePrice=create_formatted_value(
totals.get("avgBuyPrice", 0), "price", currency
),
finalBatterySoe=create_formatted_value(
totals.get("finalBatterySoe", 0), "energy_kwh_only", currency
),
)
@dataclass
class APIDashboardResponse:
"""Complete dashboard response with canonical dataclass structure."""
# Core metadata
date: str
currentPeriod: int
# Financial summary
totalDailySavings: float
actualSavingsSoFar: float
predictedRemainingSavings: float
# Data structure info
actualHoursCount: int
predictedHoursCount: int
dataSources: list[str]
# Battery state
batteryCapacity: float
batterySoc: FormattedValue
batterySoe: FormattedValue
# Main data structures
hourlyData: list[APIDashboardHourlyData]
tomorrowData: list[APIDashboardHourlyData] | None
summary: APIDashboardSummary
costAndSavings: APICostAndSavings
realTimePower: APIRealTimePower
strategicIntentSummary: dict[str, int]
@classmethod
def from_dashboard_data(
cls,
daily_view,
controller,
totals: dict,
costs: dict,
strategic_summary: dict,
battery_soc: float,
battery_capacity: float,
currency: str,
hourly_data_instances: list | None = None,
resolution: str = "quarter-hourly",
tomorrow_data: list[APIDashboardHourlyData] | None = None,
) -> APIDashboardResponse:
"""Create complete dashboard response from internal data."""
# Use pre-created hourly data instances to avoid duplication
if hourly_data_instances is not None:
hourly_data = hourly_data_instances
else:
# Fallback: create instances if not provided (for backward compatibility)
hourly_data = [
APIDashboardHourlyData.from_internal(hour, battery_capacity, currency)
for hour in daily_view.hourly_data
]
# Calculate detailed flow totals from the converted hourly data
# (detailed flows are only available after APIDashboardHourlyData conversion)
detailed_flow_totals = {
"totalSolarToHome": sum(h.solarToHome.value for h in hourly_data),
"totalSolarToBattery": sum(h.solarToBattery.value for h in hourly_data),
"totalSolarToGrid": sum(h.solarToGrid.value for h in hourly_data),
"totalGridToHome": sum(h.gridToHome.value for h in hourly_data),
"totalGridToBattery": sum(h.gridToBattery.value for h in hourly_data),
"totalBatteryToHome": sum(h.batteryToHome.value for h in hourly_data),
"totalBatteryToGrid": sum(h.batteryToGrid.value for h in hourly_data),
}
# Override battery charged/discharged totals to match detailed flows perspective
# Detailed flows represent GROSS energy (before efficiency losses)
# This ensures percentages are correct: solar_to_battery + grid_to_battery = total_charged
totals["totalBatteryCharged"] = (
detailed_flow_totals["totalSolarToBattery"]
+ detailed_flow_totals["totalGridToBattery"]
)
totals["totalBatteryDischarged"] = (
detailed_flow_totals["totalBatteryToHome"]
+ detailed_flow_totals["totalBatteryToGrid"]
)
# Combine basic totals with detailed flow totals
complete_totals = {**totals, **detailed_flow_totals}
# Create summary
summary = APIDashboardSummary.from_totals(
complete_totals, costs, battery_capacity, currency
)
# Create real-time power data
real_time_power = APIRealTimePower.from_controller(controller)
# Calculate current index based on resolution
now = time_utils.now()
if resolution == "hourly":
# For hourly resolution, use hour number (0-23)
current_index = now.hour
logger.debug(
"Hourly mode: currentPeriod=%s (hour=%s)",
current_index,
now.hour,
)
else:
# For quarterly resolution, use period index (0-95)
current_index = now.hour * 4 + now.minute // 15
logger.debug(
"Quarterly mode: currentPeriod=%s (hour=%s, minute=%s)",
current_index,
now.hour,
now.minute,
)
actual_data = [h for h in hourly_data if h.dataSource == "actual"]
predicted_data = [h for h in hourly_data if h.dataSource == "predicted"]
actual_savings = sum(h.hourlySavings.value for h in actual_data)
predicted_savings = sum(h.hourlySavings.value for h in predicted_data)
total_daily_savings = actual_savings + predicted_savings
# Battery SOE calculation
battery_soe = (battery_soc / 100.0) * battery_capacity
# Create cost and savings data structure for SystemStatusCard
cost_and_savings = APICostAndSavings(
todaysCost=summary.optimizedCost,
todaysSavings=summary.totalSavings,
gridOnlyCost=summary.gridOnlyCost,
percentageSaved=summary.totalSavingsPercentage,
)
return cls(
# Core metadata
date=daily_view.date.isoformat(),
currentPeriod=current_index,
# Financial summary
totalDailySavings=total_daily_savings,
actualSavingsSoFar=actual_savings,
predictedRemainingSavings=predicted_savings,
# Data structure info
actualHoursCount=len(actual_data),
predictedHoursCount=len(predicted_data),
dataSources=list({h.dataSource for h in hourly_data}),
# Battery state
batteryCapacity=battery_capacity,
batterySoc=create_formatted_value(battery_soc, "percentage", currency),
batterySoe=create_formatted_value(battery_soe, "energy_kwh_only", currency),
# Main data structures
hourlyData=hourly_data,
tomorrowData=tomorrow_data,
summary=summary,
costAndSavings=cost_and_savings,
realTimePower=real_time_power,
strategicIntentSummary=strategic_summary,
)
@dataclass
class APIRealTimePower:
"""Real-time power data with unified FormattedValue interface."""
# Unified formatted values (no duplicates)
solarPower: FormattedValue
homeLoadPower: FormattedValue
gridImportPower: FormattedValue
gridExportPower: FormattedValue
batteryChargePower: FormattedValue
batteryDischargePower: FormattedValue
netBatteryPower: FormattedValue
@classmethod
def from_controller(cls, controller) -> APIRealTimePower:
"""Convert from controller readings to canonical camelCase."""
# Get raw power values
solar_power = controller.get_pv_power()
home_load_power = controller.get_local_load_power()
grid_import_power = controller.get_import_power()
grid_export_power = controller.get_export_power()
battery_charge_power = controller.get_battery_charge_power()
battery_discharge_power = controller.get_battery_discharge_power()
net_battery_power = controller.get_net_battery_power()
def create_formatted_power(value):
"""Create formatted power value structure with thousands separators"""
if value is None:
value = 0
if abs(value) >= 1000:
return FormattedValue(
value=value,
display=f"{value/1000:.1f}",
unit="kW",
text=f"{value/1000:.1f} kW",
)
else:
return FormattedValue(
value=value,
display=f"{value:,.0f}",
unit="W",
text=f"{value:,.0f} W",
)
return cls(
# Unified formatted values (no duplicates)
solarPower=create_formatted_power(solar_power),
homeLoadPower=create_formatted_power(home_load_power),
gridImportPower=create_formatted_power(grid_import_power),
gridExportPower=create_formatted_power(grid_export_power),
batteryChargePower=create_formatted_power(battery_charge_power),
batteryDischargePower=create_formatted_power(battery_discharge_power),
netBatteryPower=create_formatted_power(net_battery_power),
)
# ---------------------------------------------------------------------------
# Settings API models (Pydantic — used by setup wizard and sensor validation)
# ---------------------------------------------------------------------------
# Matches valid HA entity IDs: domain.object_id (case-insensitive to handle
# user-defined entity IDs with uppercase letters in the object_id part).
_ENTITY_ID_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*\.[a-zA-Z0-9_-]+$")
@dataclass
class APIStrategyForecast:
"""API representation of a single consumption forecast strategy."""
name: str
isActive: bool
available: bool
error: str | None
totalKwh: FormattedValue | None
hourlyProfile: list[FormattedValue]
mae: FormattedValue | None
@dataclass
class APIConsumptionForecastComparison:
"""API representation of consumption forecast comparison across strategies."""
activeStrategy: str
strategies: list[APIStrategyForecast]
actualHourlyProfile: list[FormattedValue | None]
actualHoursAvailable: int
class APISensorsPayload(BaseModel):
"""Request/response body for sensor entity ID mappings."""
sensors: dict[str, str] = {}
@field_validator("sensors")
@classmethod
def validate_entity_ids(cls, sensors: dict[str, str]) -> dict[str, str]:
for value in sensors.values():
if value and not _ENTITY_ID_RE.match(value):
raise ValueError(f"Invalid entity ID format: {value}")
return sensors
class APISetupCompletePayload(BaseModel):
"""Request body for POST /api/setup/complete — full wizard output."""
sensors: dict[str, str | dict[str, str]] = {}
nordpoolArea: str | None = None
nordpoolConfigEntryId: str | None = None
growattDeviceId: str | None = None
# Battery settings
totalCapacity: float | None = None
minSoc: float | None = None
maxSoc: float | None = None
maxChargeDischargePower: float | None = None
cycleCost: float | None = None
minActionProfitThreshold: float | None = None
externalSolarMode: bool | None = None
# Home settings
currency: str | None = None
consumption: float | None = None
consumptionStrategy: str | None = None
maxFuseCurrent: int | None = None
voltage: int | None = None
safetyMarginFactor: float | None = None
phaseCount: int | None = None
powerMonitoringEnabled: bool | None = None
# Electricity price settings
area: str | None = None
markupRate: float | None = None
vatMultiplier: float | None = None
additionalCosts: float | None = None
taxReduction: float | None = None
# Energy provider
provider: str | None = None
# Nordpool HACS entity (required when provider == "nordpool_hacs")
nordpoolEntity: str | None = None