Commit ·
4429cbc
1
Parent(s): 60b35f9
Add Flask ML service (app, predictor, thingsboard client)
Browse files- ml_service/app.py +1756 -0
- ml_service/nilm_v9_predictor.py +487 -0
- ml_service/run.ps1 +14 -0
- ml_service/thingsboard_client.py +175 -0
ml_service/app.py
ADDED
|
@@ -0,0 +1,1756 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import tempfile
|
| 4 |
+
import zipfile
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
from collections import deque
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from threading import Lock
|
| 9 |
+
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
import numpy as np
|
| 12 |
+
import requests
|
| 13 |
+
from flask import Flask, jsonify, request
|
| 14 |
+
from flask_cors import CORS
|
| 15 |
+
|
| 16 |
+
# Load environment variables from the repository root .env file if available.
|
| 17 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 18 |
+
load_dotenv(ROOT_DIR / ".env")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _normalize_model_dir(value: str | None) -> str:
|
| 22 |
+
if not value:
|
| 23 |
+
return "src/nilm_models_v9"
|
| 24 |
+
normalized = value.strip()
|
| 25 |
+
if normalized.startswith("@file:"):
|
| 26 |
+
normalized = normalized[len("@file:"):]
|
| 27 |
+
if normalized.startswith("file://"):
|
| 28 |
+
normalized = normalized[len("file://"):]
|
| 29 |
+
if normalized.startswith("file:"):
|
| 30 |
+
normalized = normalized[len("file:"):]
|
| 31 |
+
return normalized
|
| 32 |
+
|
| 33 |
+
_model_dir = Path(_normalize_model_dir(os.environ.get("NILM_MODEL_DIR")))
|
| 34 |
+
MODEL_DIR = _model_dir if _model_dir.is_absolute() else (ROOT_DIR / _model_dir)
|
| 35 |
+
MODEL_DIR = MODEL_DIR.resolve()
|
| 36 |
+
_DUMMY_FILE = Path(__file__).resolve().parent / "dummy_blynk_samples.json"
|
| 37 |
+
_MODEL_TEXT_FILES = ("config.json", "metadata.json", "labels.json", "meta_nilm.json")
|
| 38 |
+
_MODEL_BINARY_FILES = ("model.weights.h5",)
|
| 39 |
+
_NOTEBOOK_GLOB = "*.ipynb"
|
| 40 |
+
_MODEL_ARCHIVE_GLOB = "*.keras"
|
| 41 |
+
|
| 42 |
+
app = Flask(__name__)
|
| 43 |
+
_cors_raw = os.environ.get("CORS_ORIGINS", "*").strip()
|
| 44 |
+
_cors_origins = (
|
| 45 |
+
[origin.strip() for origin in _cors_raw.split(",") if origin.strip()]
|
| 46 |
+
if _cors_raw and _cors_raw != "*"
|
| 47 |
+
else "*"
|
| 48 |
+
)
|
| 49 |
+
CORS(
|
| 50 |
+
app,
|
| 51 |
+
resources={r"/*": {"origins": _cors_origins}},
|
| 52 |
+
supports_credentials=False,
|
| 53 |
+
methods=["GET", "POST", "OPTIONS"],
|
| 54 |
+
allow_headers=["Content-Type", "Accept"],
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
_MODEL = None
|
| 58 |
+
_LABELS_CACHE = None
|
| 59 |
+
_LABEL_SOURCE_CACHE = None
|
| 60 |
+
_LABELS_CACHE_KEY = None
|
| 61 |
+
_MODEL_META_CACHE = None
|
| 62 |
+
_EMA_PROBS = None
|
| 63 |
+
_PRED_QUEUE = deque(maxlen=5)
|
| 64 |
+
_PRED_DEVICE_QUEUE = deque(maxlen=5)
|
| 65 |
+
_PREV_POWER = None
|
| 66 |
+
_LATEST_RESULT = None
|
| 67 |
+
_REQUEST_COUNT = 0
|
| 68 |
+
_SEQ_BUFFER = deque(maxlen=99)
|
| 69 |
+
_LAST_RAW_SAMPLE = None
|
| 70 |
+
_LOCK = Lock()
|
| 71 |
+
_V9_PREDICTOR = None
|
| 72 |
+
_V9_PREDICTOR_KEY: tuple[str, int, int] | None = None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _get_v9_predictor():
|
| 76 |
+
global _V9_PREDICTOR, _V9_PREDICTOR_KEY
|
| 77 |
+
meta_path = MODEL_DIR / "meta_nilm.json"
|
| 78 |
+
keras_path = MODEL_DIR / "best_nilm_model.keras"
|
| 79 |
+
cache_key = None
|
| 80 |
+
if meta_path.exists() and keras_path.exists():
|
| 81 |
+
meta_stat = meta_path.stat()
|
| 82 |
+
keras_stat = keras_path.stat()
|
| 83 |
+
cache_key = (
|
| 84 |
+
str(MODEL_DIR),
|
| 85 |
+
meta_stat.st_mtime_ns,
|
| 86 |
+
keras_stat.st_size,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
if _V9_PREDICTOR is None or _V9_PREDICTOR_KEY != cache_key:
|
| 90 |
+
from nilm_v9_predictor import NilmV9Predictor
|
| 91 |
+
|
| 92 |
+
_V9_PREDICTOR = NilmV9Predictor(MODEL_DIR)
|
| 93 |
+
_V9_PREDICTOR_KEY = cache_key
|
| 94 |
+
return _V9_PREDICTOR
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _predictor_to_response(pred: dict, sample: dict):
|
| 98 |
+
meta = _read_model_meta()
|
| 99 |
+
if _LABEL_SOURCE_CACHE is None:
|
| 100 |
+
_load_labels()
|
| 101 |
+
label_source = _LABEL_SOURCE_CACHE or "meta_nilm.json:devices"
|
| 102 |
+
|
| 103 |
+
input_shape = meta.get("input_shape") or [30, 8]
|
| 104 |
+
seq_len = int(input_shape[0]) if input_shape else 30
|
| 105 |
+
received_len = int(pred.get("buffer_fill") or 0)
|
| 106 |
+
raw_status = str(pred.get("buffer_status") or "").lower()
|
| 107 |
+
|
| 108 |
+
if raw_status == "warming":
|
| 109 |
+
buffer_status = "WARMING"
|
| 110 |
+
elif raw_status == "ready":
|
| 111 |
+
buffer_status = "READY"
|
| 112 |
+
elif received_len < max(10, seq_len // 3):
|
| 113 |
+
buffer_status = "WARMING"
|
| 114 |
+
elif received_len < seq_len:
|
| 115 |
+
buffer_status = "LOADING"
|
| 116 |
+
else:
|
| 117 |
+
buffer_status = "READY"
|
| 118 |
+
|
| 119 |
+
label = str(pred.get("label") or "idle")
|
| 120 |
+
if label == "filling_buffer":
|
| 121 |
+
label = "idle"
|
| 122 |
+
|
| 123 |
+
active_devices = list(pred.get("active_devices") or [])
|
| 124 |
+
prob_map = {device: float(probability) for device, probability in pred.get("probs") or []}
|
| 125 |
+
devices = meta.get("devices") or _load_labels()
|
| 126 |
+
device_probs = [
|
| 127 |
+
{"device": device, "probability": round(prob_map.get(device, 0.0) * 100.0, 1)}
|
| 128 |
+
for device in devices
|
| 129 |
+
if isinstance(device, str)
|
| 130 |
+
]
|
| 131 |
+
|
| 132 |
+
predictor_confidence = pred.get("confidence")
|
| 133 |
+
if isinstance(predictor_confidence, (int, float)):
|
| 134 |
+
chosen_confidence = float(predictor_confidence)
|
| 135 |
+
elif active_devices:
|
| 136 |
+
active_probs = [prob_map.get(device, 0.0) for device in active_devices]
|
| 137 |
+
max_prob = max(active_probs) if active_probs else 0.0
|
| 138 |
+
min_prob = min(active_probs) if active_probs else 0.0
|
| 139 |
+
chosen_confidence = (0.3 * min_prob + 0.7 * max_prob) * 100.0
|
| 140 |
+
else:
|
| 141 |
+
chosen_confidence = max(0.0, 1.0 - max(prob_map.values(), default=0.0)) * 100.0
|
| 142 |
+
|
| 143 |
+
return {
|
| 144 |
+
"success": True,
|
| 145 |
+
"label": label,
|
| 146 |
+
"confidence": round(chosen_confidence, 1),
|
| 147 |
+
"index": 0,
|
| 148 |
+
"model_version": pred.get("model_version") or meta.get("model_name") or "unknown_model",
|
| 149 |
+
"label_source": label_source,
|
| 150 |
+
"timestamp": _now_iso(),
|
| 151 |
+
"problem_type": meta.get("problem_type") or "multilabel",
|
| 152 |
+
"active_devices": active_devices,
|
| 153 |
+
"device_probs": device_probs,
|
| 154 |
+
"buffer": {
|
| 155 |
+
"received": received_len,
|
| 156 |
+
"window": seq_len,
|
| 157 |
+
"status": buffer_status,
|
| 158 |
+
"bar": _format_buffer_bar(received_len, seq_len),
|
| 159 |
+
},
|
| 160 |
+
"raw_top": {
|
| 161 |
+
"label": label,
|
| 162 |
+
"confidence": round(chosen_confidence, 1),
|
| 163 |
+
"index": 0,
|
| 164 |
+
},
|
| 165 |
+
"raw_second": {
|
| 166 |
+
"label": label,
|
| 167 |
+
"confidence": round(chosen_confidence, 1),
|
| 168 |
+
"index": 0,
|
| 169 |
+
},
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _read_json(path: Path):
|
| 174 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _read_text(path: Path):
|
| 178 |
+
return path.read_text(encoding="utf-8")
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _sanitize_keras_config(value):
|
| 182 |
+
if isinstance(value, dict):
|
| 183 |
+
return {
|
| 184 |
+
key: _sanitize_keras_config(item)
|
| 185 |
+
for key, item in value.items()
|
| 186 |
+
if key not in {"quantization_config"}
|
| 187 |
+
}
|
| 188 |
+
if isinstance(value, list):
|
| 189 |
+
return [_sanitize_keras_config(item) for item in value]
|
| 190 |
+
return value
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _get_custom_objects(tf):
|
| 194 |
+
try:
|
| 195 |
+
register = tf.keras.saving.register_keras_serializable(package="nilm_v9")
|
| 196 |
+
except AttributeError:
|
| 197 |
+
try:
|
| 198 |
+
register = tf.keras.utils.register_keras_serializable(package="nilm_v9")
|
| 199 |
+
except AttributeError:
|
| 200 |
+
register = lambda cls: cls
|
| 201 |
+
|
| 202 |
+
@register
|
| 203 |
+
class TemporalSum(tf.keras.layers.Layer):
|
| 204 |
+
def call(self, inputs):
|
| 205 |
+
return tf.reduce_sum(inputs, axis=1)
|
| 206 |
+
|
| 207 |
+
def get_config(self):
|
| 208 |
+
return super().get_config()
|
| 209 |
+
|
| 210 |
+
def weighted_bce(y_true, y_pred):
|
| 211 |
+
return tf.reduce_mean(tf.keras.losses.binary_crossentropy(y_true, y_pred))
|
| 212 |
+
|
| 213 |
+
def exact_match(y_true, y_pred):
|
| 214 |
+
pred_bin = tf.cast(y_pred >= 0.5, tf.float32)
|
| 215 |
+
match = tf.reduce_all(tf.equal(pred_bin, y_true), axis=1)
|
| 216 |
+
return tf.reduce_mean(tf.cast(match, tf.float32))
|
| 217 |
+
|
| 218 |
+
return {
|
| 219 |
+
"TemporalSum": TemporalSum,
|
| 220 |
+
"weighted_bce": weighted_bce,
|
| 221 |
+
"exact_match": exact_match,
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _load_model_from_archive(keras_file: Path, tf, custom_objects):
|
| 226 |
+
with zipfile.ZipFile(keras_file) as archive:
|
| 227 |
+
sanitized_config = _sanitize_keras_config(json.loads(archive.read("config.json").decode("utf-8")))
|
| 228 |
+
weights_bytes = archive.read("model.weights.h5")
|
| 229 |
+
|
| 230 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 231 |
+
weights_path = Path(temp_dir) / "model.weights.h5"
|
| 232 |
+
weights_path.write_bytes(weights_bytes)
|
| 233 |
+
|
| 234 |
+
model = tf.keras.models.model_from_json(json.dumps(sanitized_config), custom_objects=custom_objects)
|
| 235 |
+
model.load_weights(str(weights_path))
|
| 236 |
+
return model
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _load_model_from_files(root: Path, tf, custom_objects):
|
| 240 |
+
config_path = root / "config.json"
|
| 241 |
+
weights_path = root / "model.weights.h5"
|
| 242 |
+
sanitized_config = _sanitize_keras_config(_read_json(config_path))
|
| 243 |
+
model = tf.keras.models.model_from_json(json.dumps(sanitized_config), custom_objects=custom_objects)
|
| 244 |
+
model.load_weights(str(weights_path))
|
| 245 |
+
return model
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _model_root() -> Path:
|
| 249 |
+
return MODEL_DIR if MODEL_DIR.is_dir() else MODEL_DIR.parent
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def _find_keras_file() -> Path | None:
|
| 253 |
+
if MODEL_DIR.is_file() and MODEL_DIR.suffix == ".keras":
|
| 254 |
+
return MODEL_DIR
|
| 255 |
+
|
| 256 |
+
if MODEL_DIR.is_dir():
|
| 257 |
+
preferred = MODEL_DIR / "best_nilm_model.keras"
|
| 258 |
+
if preferred.exists():
|
| 259 |
+
return preferred
|
| 260 |
+
|
| 261 |
+
candidates = sorted(MODEL_DIR.glob(_MODEL_ARCHIVE_GLOB))
|
| 262 |
+
if candidates:
|
| 263 |
+
return candidates[0]
|
| 264 |
+
|
| 265 |
+
return None
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _get_model_files():
|
| 269 |
+
root = _model_root()
|
| 270 |
+
files = []
|
| 271 |
+
for name in (*_MODEL_TEXT_FILES, *_MODEL_BINARY_FILES):
|
| 272 |
+
path = root / name
|
| 273 |
+
files.append(
|
| 274 |
+
{
|
| 275 |
+
"name": name,
|
| 276 |
+
"exists": path.exists(),
|
| 277 |
+
"size_bytes": path.stat().st_size if path.exists() else None,
|
| 278 |
+
"type": "text" if name in _MODEL_TEXT_FILES else "binary",
|
| 279 |
+
}
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
keras_file = _find_keras_file()
|
| 283 |
+
if keras_file is not None:
|
| 284 |
+
files.append(
|
| 285 |
+
{
|
| 286 |
+
"name": keras_file.name,
|
| 287 |
+
"exists": True,
|
| 288 |
+
"size_bytes": keras_file.stat().st_size,
|
| 289 |
+
"type": "binary",
|
| 290 |
+
}
|
| 291 |
+
)
|
| 292 |
+
return files
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _resolve_model_file(name: str):
|
| 296 |
+
normalized = Path(name).name
|
| 297 |
+
allowed_files = set(_MODEL_TEXT_FILES) | set(_MODEL_BINARY_FILES)
|
| 298 |
+
keras_file = _find_keras_file()
|
| 299 |
+
if keras_file is not None:
|
| 300 |
+
allowed_files.add(keras_file.name)
|
| 301 |
+
|
| 302 |
+
if normalized not in allowed_files:
|
| 303 |
+
raise ValueError("File tidak diizinkan. Gunakan config.json, metadata.json, labels.json, meta_nilm.json, model.weights.h5, atau file .keras model")
|
| 304 |
+
|
| 305 |
+
root = _model_root()
|
| 306 |
+
if normalized == keras_file.name if keras_file is not None else False:
|
| 307 |
+
return keras_file
|
| 308 |
+
|
| 309 |
+
return root / normalized
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _extract_notebook_classes():
|
| 313 |
+
candidates = sorted(MODEL_DIR.glob(_NOTEBOOK_GLOB))
|
| 314 |
+
for notebook_path in candidates:
|
| 315 |
+
try:
|
| 316 |
+
notebook = _read_json(notebook_path)
|
| 317 |
+
except Exception:
|
| 318 |
+
continue
|
| 319 |
+
|
| 320 |
+
for cell in notebook.get("cells", []):
|
| 321 |
+
for line in cell.get("source", []):
|
| 322 |
+
if "\"classes\":" in line.lower():
|
| 323 |
+
try:
|
| 324 |
+
snippet = line[line.index("{"):]
|
| 325 |
+
payload = json.loads(snippet)
|
| 326 |
+
classes = payload.get("classes")
|
| 327 |
+
if isinstance(classes, list) and all(isinstance(item, str) for item in classes):
|
| 328 |
+
return [item.strip() for item in classes if item.strip()], notebook_path.name
|
| 329 |
+
except Exception:
|
| 330 |
+
continue
|
| 331 |
+
for cell in notebook.get("cells", []):
|
| 332 |
+
source = "".join(cell.get("source", []))
|
| 333 |
+
marker = "CLASSES = ["
|
| 334 |
+
if marker not in source:
|
| 335 |
+
continue
|
| 336 |
+
try:
|
| 337 |
+
start = source.index(marker) + len(marker)
|
| 338 |
+
end = source.index("]", start)
|
| 339 |
+
raw_items = source[start:end].splitlines()
|
| 340 |
+
classes = [item.strip().strip(",").strip("'\"") for item in raw_items]
|
| 341 |
+
classes = [item for item in classes if item]
|
| 342 |
+
if classes:
|
| 343 |
+
return classes, notebook_path.name
|
| 344 |
+
except Exception:
|
| 345 |
+
continue
|
| 346 |
+
return None, None
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def _read_model_meta():
|
| 350 |
+
global _MODEL_META_CACHE
|
| 351 |
+
if _MODEL_META_CACHE is not None:
|
| 352 |
+
return _MODEL_META_CACHE
|
| 353 |
+
|
| 354 |
+
root = _model_root()
|
| 355 |
+
meta_path = root / "meta_nilm.json"
|
| 356 |
+
if not meta_path.exists():
|
| 357 |
+
meta_path = root / "metadata.json"
|
| 358 |
+
|
| 359 |
+
if meta_path.exists():
|
| 360 |
+
meta = _read_json(meta_path)
|
| 361 |
+
model_name = meta.get("model_version") or "unknown_model"
|
| 362 |
+
input_shape = []
|
| 363 |
+
if isinstance(meta.get("window_size"), int) and isinstance(meta.get("n_features"), int):
|
| 364 |
+
input_shape = [meta["window_size"], meta["n_features"]]
|
| 365 |
+
|
| 366 |
+
devices = meta.get("devices")
|
| 367 |
+
has_device_list = isinstance(devices, list) and all(isinstance(item, str) for item in devices)
|
| 368 |
+
problem_type = "multilabel" if has_device_list else "multiclass"
|
| 369 |
+
output_units = meta.get("n_classes")
|
| 370 |
+
if not isinstance(output_units, int) or output_units <= 0:
|
| 371 |
+
if problem_type == "multilabel" and isinstance(meta.get("n_devices"), int) and meta["n_devices"] > 0:
|
| 372 |
+
output_units = meta["n_devices"]
|
| 373 |
+
elif isinstance(meta.get("classes"), list):
|
| 374 |
+
output_units = len([item for item in meta["classes"] if isinstance(item, str) and item.strip()])
|
| 375 |
+
else:
|
| 376 |
+
session_to_label = meta.get("session_to_label")
|
| 377 |
+
if isinstance(session_to_label, dict):
|
| 378 |
+
output_units = len(session_to_label)
|
| 379 |
+
|
| 380 |
+
if has_device_list:
|
| 381 |
+
devices = [item.strip() for item in devices if isinstance(item, str) and item.strip()]
|
| 382 |
+
else:
|
| 383 |
+
devices = None
|
| 384 |
+
|
| 385 |
+
_MODEL_META_CACHE = {
|
| 386 |
+
"model_name": model_name,
|
| 387 |
+
"input_shape": input_shape,
|
| 388 |
+
"output_units": output_units,
|
| 389 |
+
"problem_type": problem_type,
|
| 390 |
+
"devices": devices,
|
| 391 |
+
"threshold": meta.get("threshold"),
|
| 392 |
+
"device_thresholds": meta.get("device_thresholds"),
|
| 393 |
+
"smooth_n": meta.get("smooth_n"),
|
| 394 |
+
"session_to_label": meta.get("session_to_label"),
|
| 395 |
+
"scaler_mean": meta.get("scaler_mean"),
|
| 396 |
+
"scaler_scale": meta.get("scaler_scale"),
|
| 397 |
+
"noise_floor_w": meta.get("noise_floor_w"),
|
| 398 |
+
"transition_delta": meta.get("transition_delta"),
|
| 399 |
+
"conf_thresh": meta.get("conf_thresh"),
|
| 400 |
+
"power_range": meta.get("power_range"),
|
| 401 |
+
"device_display": meta.get("device_display"),
|
| 402 |
+
"feature_cols": meta.get("feature_cols"),
|
| 403 |
+
}
|
| 404 |
+
return _MODEL_META_CACHE
|
| 405 |
+
|
| 406 |
+
config = _read_json(root / "config.json")
|
| 407 |
+
layers = config.get("config", {}).get("layers", [])
|
| 408 |
+
input_layer = next((layer for layer in layers if layer.get("class_name") == "InputLayer"), None)
|
| 409 |
+
output_layer = next(
|
| 410 |
+
(
|
| 411 |
+
layer
|
| 412 |
+
for layer in reversed(layers)
|
| 413 |
+
if layer.get("class_name") == "Dense" and layer.get("config", {}).get("activation") == "softmax"
|
| 414 |
+
),
|
| 415 |
+
None,
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
input_shape = (input_layer or {}).get("config", {}).get("batch_shape") or []
|
| 419 |
+
input_shape = [value for value in input_shape if isinstance(value, int)]
|
| 420 |
+
output_units = (output_layer or {}).get("config", {}).get("units")
|
| 421 |
+
model_name = config.get("config", {}).get("name") or "unknown_model"
|
| 422 |
+
|
| 423 |
+
_MODEL_META_CACHE = {
|
| 424 |
+
"model_name": model_name,
|
| 425 |
+
"input_shape": input_shape,
|
| 426 |
+
"output_units": output_units,
|
| 427 |
+
"problem_type": "multiclass",
|
| 428 |
+
}
|
| 429 |
+
return _MODEL_META_CACHE
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def _load_labels():
|
| 433 |
+
global _LABELS_CACHE, _LABEL_SOURCE_CACHE, _LABELS_CACHE_KEY
|
| 434 |
+
root = _model_root()
|
| 435 |
+
labels_path = root / "labels.json"
|
| 436 |
+
meta_path = root / "meta_nilm.json"
|
| 437 |
+
cache_key = None
|
| 438 |
+
|
| 439 |
+
if meta_path.exists():
|
| 440 |
+
stat = meta_path.stat()
|
| 441 |
+
cache_key = (str(meta_path), stat.st_mtime_ns, stat.st_size)
|
| 442 |
+
elif labels_path.exists():
|
| 443 |
+
stat = labels_path.stat()
|
| 444 |
+
cache_key = (str(labels_path), stat.st_mtime_ns, stat.st_size)
|
| 445 |
+
|
| 446 |
+
if _LABELS_CACHE is not None and _LABELS_CACHE_KEY == cache_key:
|
| 447 |
+
return _LABELS_CACHE
|
| 448 |
+
|
| 449 |
+
_LABELS_CACHE = None
|
| 450 |
+
_LABEL_SOURCE_CACHE = None
|
| 451 |
+
_LABELS_CACHE_KEY = cache_key
|
| 452 |
+
meta = _read_model_meta()
|
| 453 |
+
output_units = meta.get("output_units")
|
| 454 |
+
|
| 455 |
+
if meta_path.exists():
|
| 456 |
+
meta = _read_json(meta_path)
|
| 457 |
+
devices = meta.get("devices")
|
| 458 |
+
classes = meta.get("classes")
|
| 459 |
+
labels = []
|
| 460 |
+
|
| 461 |
+
if isinstance(devices, list) and all(isinstance(item, str) for item in devices):
|
| 462 |
+
labels = [item.strip() for item in devices if item.strip()]
|
| 463 |
+
_LABEL_SOURCE_CACHE = "meta_nilm.json:devices"
|
| 464 |
+
elif classes is None:
|
| 465 |
+
session_to_label = meta.get("session_to_label")
|
| 466 |
+
if isinstance(session_to_label, dict):
|
| 467 |
+
seen = set()
|
| 468 |
+
for label in session_to_label.values():
|
| 469 |
+
if isinstance(label, str):
|
| 470 |
+
label = label.strip()
|
| 471 |
+
if label and label not in seen:
|
| 472 |
+
seen.add(label)
|
| 473 |
+
labels.append(label)
|
| 474 |
+
else:
|
| 475 |
+
raise ValueError("meta_nilm.json invalid: field 'classes' harus array string, field 'devices' harus array string, atau field 'session_to_label' harus object string")
|
| 476 |
+
_LABEL_SOURCE_CACHE = "meta_nilm.json:session_to_label"
|
| 477 |
+
else:
|
| 478 |
+
if not isinstance(classes, list) or not all(isinstance(item, str) for item in classes):
|
| 479 |
+
raise ValueError("meta_nilm.json invalid: field 'classes' harus array string")
|
| 480 |
+
labels = [item.strip() for item in classes if item.strip()]
|
| 481 |
+
_LABEL_SOURCE_CACHE = "meta_nilm.json:classes"
|
| 482 |
+
|
| 483 |
+
if isinstance(output_units, int) and output_units > 0 and len(labels) != output_units:
|
| 484 |
+
raise ValueError(f"Jumlah label runtime ({len(labels)}) tidak cocok dengan output model ({output_units})")
|
| 485 |
+
elif labels_path.exists():
|
| 486 |
+
configured_labels = _read_json(labels_path).get("labels", [])
|
| 487 |
+
if not isinstance(configured_labels, list) or not all(isinstance(item, str) for item in configured_labels):
|
| 488 |
+
raise ValueError("labels.json invalid: field 'labels' harus array string")
|
| 489 |
+
|
| 490 |
+
labels = [item.strip() for item in configured_labels if isinstance(item, str) and item.strip()]
|
| 491 |
+
|
| 492 |
+
if isinstance(output_units, int) and output_units > 0:
|
| 493 |
+
if len(labels) > output_units:
|
| 494 |
+
raise ValueError(f"labels.json tidak boleh melebihi {output_units} label, sekarang {len(labels)}")
|
| 495 |
+
if len(labels) < output_units:
|
| 496 |
+
labels.extend(f"unknown_{index}" for index in range(len(labels), output_units))
|
| 497 |
+
_LABEL_SOURCE_CACHE = "labels.json"
|
| 498 |
+
else:
|
| 499 |
+
notebook_labels, notebook_name = _extract_notebook_classes()
|
| 500 |
+
if (
|
| 501 |
+
isinstance(output_units, int)
|
| 502 |
+
and output_units > 0
|
| 503 |
+
and isinstance(notebook_labels, list)
|
| 504 |
+
and len(notebook_labels) == output_units
|
| 505 |
+
):
|
| 506 |
+
labels = notebook_labels
|
| 507 |
+
_LABEL_SOURCE_CACHE = f"notebook:{notebook_name}"
|
| 508 |
+
elif isinstance(output_units, int) and output_units > 0:
|
| 509 |
+
labels = [f"unknown_{index}" for index in range(output_units)]
|
| 510 |
+
_LABEL_SOURCE_CACHE = "generated"
|
| 511 |
+
else:
|
| 512 |
+
raise ValueError("labels.json tidak ditemukan dan output_units model tidak dapat dibaca")
|
| 513 |
+
|
| 514 |
+
labels = [item.strip() for item in labels if isinstance(item, str) and item.strip()]
|
| 515 |
+
_LABELS_CACHE = labels
|
| 516 |
+
return _LABELS_CACHE
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def _get_label_source():
|
| 520 |
+
if _LABEL_SOURCE_CACHE is None:
|
| 521 |
+
_load_labels()
|
| 522 |
+
return _LABEL_SOURCE_CACHE
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
def _ensure_runtime_state():
|
| 526 |
+
global _SEQ_BUFFER, _PRED_QUEUE, _PRED_DEVICE_QUEUE
|
| 527 |
+
meta = _read_model_meta()
|
| 528 |
+
input_shape = meta.get("input_shape") or []
|
| 529 |
+
seq_len = int(input_shape[0]) if len(input_shape) >= 2 and isinstance(input_shape[0], int) else 99
|
| 530 |
+
smooth_n = int(meta.get("smooth_n") or 5)
|
| 531 |
+
smooth_n = max(1, smooth_n)
|
| 532 |
+
|
| 533 |
+
if _SEQ_BUFFER.maxlen != seq_len:
|
| 534 |
+
_SEQ_BUFFER = deque(list(_SEQ_BUFFER)[-seq_len:], maxlen=seq_len)
|
| 535 |
+
|
| 536 |
+
if _PRED_QUEUE.maxlen != smooth_n:
|
| 537 |
+
_PRED_QUEUE = deque(list(_PRED_QUEUE)[-smooth_n:], maxlen=smooth_n)
|
| 538 |
+
|
| 539 |
+
if _PRED_DEVICE_QUEUE.maxlen != smooth_n:
|
| 540 |
+
_PRED_DEVICE_QUEUE = deque(list(_PRED_DEVICE_QUEUE)[-smooth_n:], maxlen=smooth_n)
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def _label_to_device_key(label: str, devices: list[str]):
|
| 544 |
+
if not isinstance(label, str):
|
| 545 |
+
return None
|
| 546 |
+
|
| 547 |
+
normalized = label.strip()
|
| 548 |
+
if not normalized:
|
| 549 |
+
return None
|
| 550 |
+
if normalized == "idle":
|
| 551 |
+
return frozenset()
|
| 552 |
+
if normalized == "full_load" and devices:
|
| 553 |
+
return frozenset(devices)
|
| 554 |
+
|
| 555 |
+
parts = [item.strip() for item in normalized.split("+") if item.strip()]
|
| 556 |
+
return frozenset(parts) if parts else frozenset()
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
def _active_devices_from_label(label: str, devices: list[str]):
|
| 560 |
+
key = _label_to_device_key(label, devices)
|
| 561 |
+
if key is None:
|
| 562 |
+
return []
|
| 563 |
+
|
| 564 |
+
device_set = set(devices)
|
| 565 |
+
return [device for device in devices if device in key and device in device_set]
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def _join_active_devices(active_labels, devices: list[str]):
|
| 569 |
+
if not isinstance(active_labels, (list, tuple, set)):
|
| 570 |
+
return "idle"
|
| 571 |
+
|
| 572 |
+
active_set = {item.strip() for item in active_labels if isinstance(item, str) and item.strip()}
|
| 573 |
+
ordered = [device for device in devices if device in active_set]
|
| 574 |
+
return "+".join(ordered) if ordered else "idle"
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def _resolve_multilabel_label(active_labels, meta: dict):
|
| 578 |
+
devices = meta.get("devices")
|
| 579 |
+
if not isinstance(devices, list):
|
| 580 |
+
devices = []
|
| 581 |
+
|
| 582 |
+
lookup = _multilabel_name_lookup(meta)
|
| 583 |
+
active_set = frozenset(
|
| 584 |
+
item.strip()
|
| 585 |
+
for item in (active_labels or [])
|
| 586 |
+
if isinstance(item, str) and item.strip() and item.strip() in devices
|
| 587 |
+
)
|
| 588 |
+
if active_set in lookup:
|
| 589 |
+
return lookup[active_set]
|
| 590 |
+
|
| 591 |
+
joined = _join_active_devices(active_labels, devices)
|
| 592 |
+
key = _label_to_device_key(joined, devices)
|
| 593 |
+
if key is not None and key in lookup:
|
| 594 |
+
return lookup[key]
|
| 595 |
+
|
| 596 |
+
return joined if joined else "idle"
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
def _multilabel_name_lookup(meta: dict):
|
| 600 |
+
devices = meta.get("devices")
|
| 601 |
+
if not isinstance(devices, list):
|
| 602 |
+
return {frozenset(): "idle"}
|
| 603 |
+
|
| 604 |
+
lookup = {frozenset(): "idle"}
|
| 605 |
+
session_to_label = meta.get("session_to_label")
|
| 606 |
+
if isinstance(session_to_label, dict):
|
| 607 |
+
for label in session_to_label.values():
|
| 608 |
+
key = _label_to_device_key(label, devices)
|
| 609 |
+
if key is not None and key not in lookup:
|
| 610 |
+
lookup[key] = label.strip()
|
| 611 |
+
return lookup
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def _get_model():
|
| 615 |
+
global _MODEL
|
| 616 |
+
if _MODEL is not None:
|
| 617 |
+
return _MODEL
|
| 618 |
+
|
| 619 |
+
try:
|
| 620 |
+
import tensorflow as tf
|
| 621 |
+
except Exception as exc:
|
| 622 |
+
raise RuntimeError(f"TensorFlow/Keras tidak tersedia: {exc}") from exc
|
| 623 |
+
|
| 624 |
+
custom_objects = _get_custom_objects(tf)
|
| 625 |
+
|
| 626 |
+
model_source = MODEL_DIR
|
| 627 |
+
keras_file = _find_keras_file()
|
| 628 |
+
if keras_file is not None:
|
| 629 |
+
model_source = keras_file
|
| 630 |
+
|
| 631 |
+
try:
|
| 632 |
+
if keras_file is not None:
|
| 633 |
+
_MODEL = tf.keras.models.load_model(
|
| 634 |
+
str(model_source),
|
| 635 |
+
custom_objects=custom_objects,
|
| 636 |
+
compile=False,
|
| 637 |
+
safe_mode=False,
|
| 638 |
+
)
|
| 639 |
+
else:
|
| 640 |
+
_MODEL = _load_model_from_files(_model_root(), tf, custom_objects)
|
| 641 |
+
except Exception as exc:
|
| 642 |
+
root = _model_root()
|
| 643 |
+
config_path = root / "config.json"
|
| 644 |
+
weights_path = root / "model.weights.h5"
|
| 645 |
+
|
| 646 |
+
try:
|
| 647 |
+
if keras_file is not None:
|
| 648 |
+
_MODEL = _load_model_from_archive(keras_file, tf, custom_objects)
|
| 649 |
+
elif config_path.exists() and weights_path.exists():
|
| 650 |
+
_MODEL = _load_model_from_files(root, tf, custom_objects)
|
| 651 |
+
else:
|
| 652 |
+
raise RuntimeError(f"Gagal load model dari {model_source}: {exc}") from exc
|
| 653 |
+
except Exception as rebuild_exc:
|
| 654 |
+
raise RuntimeError(f"Gagal load model dari {model_source}: {rebuild_exc}") from rebuild_exc
|
| 655 |
+
|
| 656 |
+
return _MODEL
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def _now_iso():
|
| 660 |
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
def _blynk_update(pin: str, value: str | float | int):
|
| 664 |
+
token = (os.environ.get("BLYNK_AUTH_TOKEN") or "").strip()
|
| 665 |
+
if not token:
|
| 666 |
+
raise RuntimeError("BLYNK_AUTH_TOKEN belum di-set di environment")
|
| 667 |
+
|
| 668 |
+
base = (os.environ.get("BLYNK_BASE_URL") or "https://blynk.cloud/external/api").rstrip("/")
|
| 669 |
+
url = f"{base}/update"
|
| 670 |
+
response = requests.get(url, params={"token": token, pin: value}, timeout=10)
|
| 671 |
+
if response.status_code != 200:
|
| 672 |
+
raise RuntimeError(f"Blynk update {pin} gagal ({response.status_code}): {response.text}")
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def _parse_sequence(payload):
|
| 676 |
+
if not isinstance(payload, dict):
|
| 677 |
+
raise ValueError("Body JSON harus object")
|
| 678 |
+
|
| 679 |
+
sequence = payload.get("sequence")
|
| 680 |
+
if sequence is None:
|
| 681 |
+
raise ValueError("Field 'sequence' wajib ada")
|
| 682 |
+
|
| 683 |
+
arr = np.array(sequence, dtype=np.float32)
|
| 684 |
+
received_len = int(arr.shape[0]) if arr.ndim >= 2 else (1 if arr.size else 0)
|
| 685 |
+
meta = _read_model_meta()
|
| 686 |
+
input_shape = meta.get("input_shape") or []
|
| 687 |
+
seq_len = int(input_shape[0]) if len(input_shape) >= 2 and isinstance(input_shape[0], int) else 99
|
| 688 |
+
|
| 689 |
+
if arr.size == 0:
|
| 690 |
+
arr = np.zeros((1, 8), dtype=np.float32)
|
| 691 |
+
received_len = 0
|
| 692 |
+
|
| 693 |
+
if arr.ndim == 1:
|
| 694 |
+
if arr.size == 8:
|
| 695 |
+
arr = arr.reshape((1, 8))
|
| 696 |
+
elif arr.size == seq_len * 8:
|
| 697 |
+
arr = arr.reshape((seq_len, 8))
|
| 698 |
+
else:
|
| 699 |
+
raise ValueError(f"Shape sequence tidak valid: {arr.shape}")
|
| 700 |
+
|
| 701 |
+
if arr.ndim != 2 or arr.shape[1] != 8:
|
| 702 |
+
raise ValueError(f"Shape sequence harus (*, 8), dapat {arr.shape}")
|
| 703 |
+
|
| 704 |
+
if arr.shape[0] > seq_len:
|
| 705 |
+
arr = arr[-seq_len:, :]
|
| 706 |
+
|
| 707 |
+
if arr.shape[0] < seq_len:
|
| 708 |
+
repeat = int(np.ceil(seq_len / arr.shape[0]))
|
| 709 |
+
arr = np.tile(arr, (repeat, 1))[:seq_len]
|
| 710 |
+
|
| 711 |
+
return arr, received_len
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def _apply_smoothing(probs: np.ndarray, alpha: float):
|
| 715 |
+
global _EMA_PROBS
|
| 716 |
+
if _EMA_PROBS is None:
|
| 717 |
+
_EMA_PROBS = probs.astype(np.float32)
|
| 718 |
+
return probs
|
| 719 |
+
|
| 720 |
+
alpha = float(alpha)
|
| 721 |
+
alpha = 0.0 if alpha < 0 else 1.0 if alpha > 1 else alpha
|
| 722 |
+
_EMA_PROBS = (alpha * probs) + ((1.0 - alpha) * _EMA_PROBS)
|
| 723 |
+
return _EMA_PROBS
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def _majority_vote_label():
|
| 727 |
+
if not _PRED_QUEUE:
|
| 728 |
+
return None
|
| 729 |
+
|
| 730 |
+
counts = {}
|
| 731 |
+
order = {}
|
| 732 |
+
for index, label in enumerate(_PRED_QUEUE):
|
| 733 |
+
counts[label] = counts.get(label, 0) + 1
|
| 734 |
+
if label not in order:
|
| 735 |
+
order[label] = index
|
| 736 |
+
|
| 737 |
+
return max(counts, key=lambda label: (counts[label], -order[label]))
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
def _device_threshold(device: str, meta: dict) -> float:
|
| 741 |
+
overrides = meta.get("device_thresholds")
|
| 742 |
+
if isinstance(overrides, dict):
|
| 743 |
+
value = overrides.get(device)
|
| 744 |
+
if isinstance(value, (int, float)):
|
| 745 |
+
return float(value)
|
| 746 |
+
return float(meta.get("threshold") or 0.5)
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
def _active_devices_from_probs(probs: np.ndarray, labels: list[str], devices: list[str], meta: dict):
|
| 750 |
+
active = []
|
| 751 |
+
for device in devices:
|
| 752 |
+
if device not in labels:
|
| 753 |
+
continue
|
| 754 |
+
index = labels.index(device)
|
| 755 |
+
if float(probs[index]) >= _device_threshold(device, meta):
|
| 756 |
+
active.append(device)
|
| 757 |
+
return active
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
def _solo_power_range_fits(device: str, power_w: float, power_range: dict, margin: float = 1.15) -> bool:
|
| 761 |
+
rng = power_range.get(device)
|
| 762 |
+
if not isinstance(rng, list) or len(rng) < 2:
|
| 763 |
+
return False
|
| 764 |
+
return float(rng[0]) <= power_w <= float(rng[1]) * margin
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
def _best_power_match_label(power_w: float, meta: dict):
|
| 768 |
+
"""Cocokkan daya agregat ke session label terdekat di meta power_range."""
|
| 769 |
+
power_range = meta.get("power_range") or {}
|
| 770 |
+
noise_floor = float(meta.get("noise_floor_w") or 3.0)
|
| 771 |
+
if power_w < noise_floor:
|
| 772 |
+
return "idle"
|
| 773 |
+
|
| 774 |
+
candidates = []
|
| 775 |
+
for label, rng in power_range.items():
|
| 776 |
+
if label == "idle" or not isinstance(rng, list) or len(rng) < 2:
|
| 777 |
+
continue
|
| 778 |
+
lo, hi = float(rng[0]), float(rng[1]) * 1.15
|
| 779 |
+
if lo <= power_w <= hi:
|
| 780 |
+
center = (float(rng[0]) + float(rng[1])) / 2.0
|
| 781 |
+
candidates.append((abs(power_w - center), label))
|
| 782 |
+
|
| 783 |
+
if not candidates:
|
| 784 |
+
return None
|
| 785 |
+
candidates.sort(key=lambda item: item[0])
|
| 786 |
+
return candidates[0][1]
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
def _finalize_multilabel_devices(
|
| 790 |
+
probs: np.ndarray,
|
| 791 |
+
labels: list[str],
|
| 792 |
+
devices: list[str],
|
| 793 |
+
meta: dict,
|
| 794 |
+
power_w: float,
|
| 795 |
+
):
|
| 796 |
+
"""
|
| 797 |
+
Gabungkan output model + sidik jari daya (power_range) agar deteksi selaras training v9.
|
| 798 |
+
"""
|
| 799 |
+
noise_floor = float(meta.get("noise_floor_w") or 3.0)
|
| 800 |
+
if power_w < noise_floor:
|
| 801 |
+
return []
|
| 802 |
+
|
| 803 |
+
power_range = meta.get("power_range") or {}
|
| 804 |
+
model_active = _active_devices_from_probs(probs, labels, devices, meta)
|
| 805 |
+
power_label = _best_power_match_label(power_w, meta)
|
| 806 |
+
power_active = _active_devices_from_label(power_label, devices) if power_label not in (None, "idle") else []
|
| 807 |
+
model_label = _resolve_multilabel_label(model_active, meta)
|
| 808 |
+
max_prob = float(np.max(probs)) if probs.size else 0.0
|
| 809 |
+
|
| 810 |
+
if power_label == "idle":
|
| 811 |
+
return []
|
| 812 |
+
|
| 813 |
+
if model_label == power_label or set(model_active) == set(power_active):
|
| 814 |
+
return model_active
|
| 815 |
+
|
| 816 |
+
if max_prob < 0.52 and power_active:
|
| 817 |
+
return power_active
|
| 818 |
+
|
| 819 |
+
if power_w <= 18 and power_label == "charger_hp":
|
| 820 |
+
return power_active or ["charger_hp"]
|
| 821 |
+
|
| 822 |
+
if power_w >= 195 and "hair_dryer" not in (power_label or ""):
|
| 823 |
+
model_active = [d for d in model_active if d != "hair_dryer"]
|
| 824 |
+
|
| 825 |
+
if power_w < 45 and power_active:
|
| 826 |
+
without_high = [d for d in model_active if d not in ("hair_dryer", "laptop")]
|
| 827 |
+
if not without_high or max_prob < 0.55:
|
| 828 |
+
return power_active
|
| 829 |
+
|
| 830 |
+
pruned = []
|
| 831 |
+
for device in model_active:
|
| 832 |
+
if _solo_power_range_fits(device, power_w, power_range):
|
| 833 |
+
pruned.append(device)
|
| 834 |
+
elif device == "charger_hp" and power_label and "charger_hp" in power_label:
|
| 835 |
+
pruned.append(device)
|
| 836 |
+
|
| 837 |
+
if pruned:
|
| 838 |
+
return pruned
|
| 839 |
+
if power_active:
|
| 840 |
+
return power_active
|
| 841 |
+
return model_active
|
| 842 |
+
|
| 843 |
+
|
| 844 |
+
def _majority_vote_active_devices(devices: list[str]):
|
| 845 |
+
if not _PRED_DEVICE_QUEUE:
|
| 846 |
+
return []
|
| 847 |
+
|
| 848 |
+
votes_needed = (len(_PRED_DEVICE_QUEUE) // 2) + 1
|
| 849 |
+
counts = {device: 0 for device in devices}
|
| 850 |
+
for pred_set in _PRED_DEVICE_QUEUE:
|
| 851 |
+
for device in pred_set:
|
| 852 |
+
if device in counts:
|
| 853 |
+
counts[device] += 1
|
| 854 |
+
|
| 855 |
+
return [device for device in devices if counts[device] >= votes_needed]
|
| 856 |
+
|
| 857 |
+
|
| 858 |
+
def _format_buffer_bar(current: int, total: int, width: int = 20):
|
| 859 |
+
total = max(1, int(total))
|
| 860 |
+
current = max(0, min(int(current), total))
|
| 861 |
+
width = max(5, int(width))
|
| 862 |
+
filled = int(round((current / total) * width))
|
| 863 |
+
return "[" + ("#" * filled) + ("-" * (width - filled)) + "]"
|
| 864 |
+
|
| 865 |
+
|
| 866 |
+
def _as_float(value, default=0.0):
|
| 867 |
+
try:
|
| 868 |
+
return float(value)
|
| 869 |
+
except Exception:
|
| 870 |
+
return float(default)
|
| 871 |
+
|
| 872 |
+
|
| 873 |
+
def _make_json_safe(value):
|
| 874 |
+
if isinstance(value, dict):
|
| 875 |
+
return {str(k): _make_json_safe(v) for k, v in value.items()}
|
| 876 |
+
if isinstance(value, list):
|
| 877 |
+
return [_make_json_safe(v) for v in value]
|
| 878 |
+
if isinstance(value, tuple):
|
| 879 |
+
return tuple(_make_json_safe(v) for v in value)
|
| 880 |
+
if isinstance(value, np.generic):
|
| 881 |
+
return value.item()
|
| 882 |
+
if isinstance(value, (np.ndarray,)):
|
| 883 |
+
return _make_json_safe(value.tolist())
|
| 884 |
+
return value
|
| 885 |
+
|
| 886 |
+
|
| 887 |
+
def _power_from_sample(sample):
|
| 888 |
+
if not isinstance(sample, dict):
|
| 889 |
+
return 0.0
|
| 890 |
+
return _as_float(sample.get("power", sample.get("P")), 0.0)
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
def _build_feature_vector(sample: dict):
|
| 894 |
+
v = _as_float(sample.get("voltage", sample.get("V")))
|
| 895 |
+
i = _as_float(sample.get("current", sample.get("I")))
|
| 896 |
+
p = _as_float(sample.get("power", sample.get("P")))
|
| 897 |
+
pf = _as_float(sample.get("power_factor", sample.get("PF")))
|
| 898 |
+
hz = _as_float(sample.get("frequency", sample.get("Hz")))
|
| 899 |
+
|
| 900 |
+
pf = max(0.0, min(pf, 1.0))
|
| 901 |
+
apparent_power = v * i
|
| 902 |
+
reactive_power = apparent_power * np.sqrt(max(0.0, 1.0 - (pf ** 2)))
|
| 903 |
+
power_ratio = p / (apparent_power + 1e-6)
|
| 904 |
+
|
| 905 |
+
return np.array(
|
| 906 |
+
[v, i, p, pf, hz, apparent_power, reactive_power, power_ratio],
|
| 907 |
+
dtype=np.float32,
|
| 908 |
+
)
|
| 909 |
+
|
| 910 |
+
|
| 911 |
+
def _scale_sequence(sequence: np.ndarray, meta: dict):
|
| 912 |
+
scaler_mean = meta.get("scaler_mean")
|
| 913 |
+
scaler_scale = meta.get("scaler_scale")
|
| 914 |
+
if (
|
| 915 |
+
isinstance(scaler_mean, list)
|
| 916 |
+
and isinstance(scaler_scale, list)
|
| 917 |
+
and len(scaler_mean) == sequence.shape[1]
|
| 918 |
+
and len(scaler_scale) == sequence.shape[1]
|
| 919 |
+
):
|
| 920 |
+
mean_arr = np.array(scaler_mean, dtype=np.float32)
|
| 921 |
+
scale_arr = np.array([float(s) if float(s) != 0.0 else 1.0 for s in scaler_scale], dtype=np.float32)
|
| 922 |
+
return (sequence - mean_arr) / scale_arr
|
| 923 |
+
|
| 924 |
+
return sequence
|
| 925 |
+
|
| 926 |
+
|
| 927 |
+
def _apparent_power_from_sample(sample):
|
| 928 |
+
if not isinstance(sample, dict):
|
| 929 |
+
return 0.0
|
| 930 |
+
v = _as_float(sample.get("voltage", sample.get("V")))
|
| 931 |
+
i = _as_float(sample.get("current", sample.get("I")))
|
| 932 |
+
return v * i
|
| 933 |
+
|
| 934 |
+
|
| 935 |
+
def _should_reset_buffer_for_device_change(sample):
|
| 936 |
+
global _PREV_POWER
|
| 937 |
+
if not isinstance(sample, dict):
|
| 938 |
+
return False
|
| 939 |
+
|
| 940 |
+
current_power = _power_from_sample(sample)
|
| 941 |
+
apparent_power = _apparent_power_from_sample(sample)
|
| 942 |
+
if _PREV_POWER is None:
|
| 943 |
+
_PREV_POWER = apparent_power
|
| 944 |
+
return False
|
| 945 |
+
|
| 946 |
+
meta = _read_model_meta()
|
| 947 |
+
transition_delta = float(meta.get("transition_delta") or 30.0)
|
| 948 |
+
noise_floor_w = float(meta.get("noise_floor_w") or 3.0)
|
| 949 |
+
|
| 950 |
+
should_reset = (
|
| 951 |
+
abs(apparent_power - _PREV_POWER) > transition_delta
|
| 952 |
+
or (_PREV_POWER <= noise_floor_w and current_power > noise_floor_w)
|
| 953 |
+
or (_PREV_POWER > noise_floor_w and current_power <= noise_floor_w)
|
| 954 |
+
)
|
| 955 |
+
_PREV_POWER = apparent_power
|
| 956 |
+
return should_reset
|
| 957 |
+
|
| 958 |
+
|
| 959 |
+
def _normalize_sample(payload):
|
| 960 |
+
if not isinstance(payload, dict):
|
| 961 |
+
raise ValueError("Body JSON harus object")
|
| 962 |
+
|
| 963 |
+
for key in ("sample", "telemetry", "data"):
|
| 964 |
+
candidate = payload.get(key)
|
| 965 |
+
if isinstance(candidate, dict):
|
| 966 |
+
return candidate
|
| 967 |
+
|
| 968 |
+
if all(key in payload for key in ("voltage", "current", "power")):
|
| 969 |
+
return payload
|
| 970 |
+
|
| 971 |
+
return payload
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
def _sample_to_dict(sample):
|
| 975 |
+
return {
|
| 976 |
+
"voltage": _as_float(sample.get("voltage", sample.get("V"))),
|
| 977 |
+
"current": _as_float(sample.get("current", sample.get("I"))),
|
| 978 |
+
"power": _as_float(sample.get("power", sample.get("P"))),
|
| 979 |
+
"energy": _as_float(sample.get("energy", sample.get("E"))),
|
| 980 |
+
"power_factor": _as_float(sample.get("power_factor", sample.get("PF"))),
|
| 981 |
+
"frequency": _as_float(sample.get("frequency", sample.get("Hz"))),
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
|
| 985 |
+
def _device_probs_payload(labels, probs, devices=None):
|
| 986 |
+
order = devices if isinstance(devices, list) and devices else labels
|
| 987 |
+
label_to_index = {label: index for index, label in enumerate(labels)}
|
| 988 |
+
payload = []
|
| 989 |
+
|
| 990 |
+
for device in order:
|
| 991 |
+
index = label_to_index.get(device)
|
| 992 |
+
if index is None:
|
| 993 |
+
continue
|
| 994 |
+
payload.append(
|
| 995 |
+
{
|
| 996 |
+
"device": device,
|
| 997 |
+
"probability": round(float(probs[index]) * 100.0, 1),
|
| 998 |
+
}
|
| 999 |
+
)
|
| 1000 |
+
|
| 1001 |
+
for index, label in enumerate(labels):
|
| 1002 |
+
if label in order:
|
| 1003 |
+
continue
|
| 1004 |
+
payload.append(
|
| 1005 |
+
{
|
| 1006 |
+
"device": label,
|
| 1007 |
+
"probability": round(float(probs[index]) * 100.0, 1),
|
| 1008 |
+
}
|
| 1009 |
+
)
|
| 1010 |
+
|
| 1011 |
+
return payload
|
| 1012 |
+
|
| 1013 |
+
|
| 1014 |
+
def _build_latest_result(sample, response_payload):
|
| 1015 |
+
sample_data = _sample_to_dict(sample)
|
| 1016 |
+
data = {
|
| 1017 |
+
**sample_data,
|
| 1018 |
+
"device_detected": response_payload["label"],
|
| 1019 |
+
"confidence": response_payload["confidence"],
|
| 1020 |
+
"model_version": response_payload["model_version"],
|
| 1021 |
+
"timestamp": response_payload["timestamp"],
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
if response_payload.get("active_devices") is not None:
|
| 1025 |
+
data["active_devices"] = response_payload["active_devices"]
|
| 1026 |
+
if response_payload.get("device_probs") is not None:
|
| 1027 |
+
data["device_probs"] = response_payload["device_probs"]
|
| 1028 |
+
|
| 1029 |
+
buffer = response_payload.get("buffer") or {}
|
| 1030 |
+
if buffer.get("status"):
|
| 1031 |
+
data["buffer_status"] = buffer["status"]
|
| 1032 |
+
|
| 1033 |
+
return {
|
| 1034 |
+
"success": True,
|
| 1035 |
+
"data": data,
|
| 1036 |
+
"meta": {
|
| 1037 |
+
"label_source": response_payload.get("label_source"),
|
| 1038 |
+
"buffer": response_payload.get("buffer"),
|
| 1039 |
+
"raw_top": response_payload.get("raw_top"),
|
| 1040 |
+
"raw_second": response_payload.get("raw_second"),
|
| 1041 |
+
"problem_type": response_payload.get("problem_type"),
|
| 1042 |
+
},
|
| 1043 |
+
}
|
| 1044 |
+
|
| 1045 |
+
|
| 1046 |
+
def _min_buffer_len(meta: dict) -> int:
|
| 1047 |
+
input_shape = meta.get("input_shape") or []
|
| 1048 |
+
seq_len = int(input_shape[0]) if len(input_shape) >= 2 and isinstance(input_shape[0], int) else 30
|
| 1049 |
+
return max(10, seq_len // 3)
|
| 1050 |
+
|
| 1051 |
+
|
| 1052 |
+
def _build_warming_response(sample, received_len: int = 0):
|
| 1053 |
+
"""Response saat buffer belum cukup (selaras dengan nilm_inference.py MIN_BUF)."""
|
| 1054 |
+
meta = _read_model_meta()
|
| 1055 |
+
if _LABEL_SOURCE_CACHE is None:
|
| 1056 |
+
_load_labels()
|
| 1057 |
+
label_source = _LABEL_SOURCE_CACHE or "meta_nilm.json:devices"
|
| 1058 |
+
|
| 1059 |
+
input_shape = meta.get("input_shape") or []
|
| 1060 |
+
seq_len = int(input_shape[0]) if len(input_shape) >= 2 and isinstance(input_shape[0], int) else 30
|
| 1061 |
+
devices = meta.get("devices")
|
| 1062 |
+
if not isinstance(devices, list) or not devices:
|
| 1063 |
+
devices = _load_labels()
|
| 1064 |
+
|
| 1065 |
+
min_buf = _min_buffer_len(meta)
|
| 1066 |
+
confidence = max(5.0, min(25.0, (received_len / max(min_buf, 1)) * 20.0))
|
| 1067 |
+
|
| 1068 |
+
return {
|
| 1069 |
+
"success": True,
|
| 1070 |
+
"label": "idle",
|
| 1071 |
+
"confidence": round(confidence, 1),
|
| 1072 |
+
"index": 0,
|
| 1073 |
+
"model_version": meta.get("model_name") or "unknown_model",
|
| 1074 |
+
"label_source": label_source,
|
| 1075 |
+
"timestamp": _now_iso(),
|
| 1076 |
+
"problem_type": meta.get("problem_type") or "multilabel",
|
| 1077 |
+
"active_devices": [],
|
| 1078 |
+
"device_probs": [
|
| 1079 |
+
{"device": device, "probability": 0.0}
|
| 1080 |
+
for device in devices
|
| 1081 |
+
if isinstance(device, str) and device.strip()
|
| 1082 |
+
],
|
| 1083 |
+
"buffer": {
|
| 1084 |
+
"received": received_len,
|
| 1085 |
+
"window": seq_len,
|
| 1086 |
+
"status": "WARMING",
|
| 1087 |
+
"bar": _format_buffer_bar(received_len, seq_len),
|
| 1088 |
+
},
|
| 1089 |
+
"raw_top": {"label": "idle", "confidence": round(confidence, 1), "index": 0},
|
| 1090 |
+
"raw_second": {"label": "idle", "confidence": round(confidence, 1), "index": 0},
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
|
| 1094 |
+
def _build_idle_response(sample, received_len: int = 0):
|
| 1095 |
+
"""Response idle tanpa inferensi model (buffer kosong atau daya di bawah noise floor)."""
|
| 1096 |
+
meta = _read_model_meta()
|
| 1097 |
+
if _LABEL_SOURCE_CACHE is None:
|
| 1098 |
+
_load_labels()
|
| 1099 |
+
label_source = _LABEL_SOURCE_CACHE or "meta_nilm.json:devices"
|
| 1100 |
+
|
| 1101 |
+
input_shape = meta.get("input_shape") or []
|
| 1102 |
+
seq_len = int(input_shape[0]) if len(input_shape) >= 2 and isinstance(input_shape[0], int) else 30
|
| 1103 |
+
devices = meta.get("devices")
|
| 1104 |
+
if not isinstance(devices, list) or not devices:
|
| 1105 |
+
devices = _load_labels()
|
| 1106 |
+
|
| 1107 |
+
power_w = _power_from_sample(sample)
|
| 1108 |
+
noise_floor_w = float(meta.get("noise_floor_w") or 3.0)
|
| 1109 |
+
buffer_status = "READY" if power_w < noise_floor_w else "LOADING"
|
| 1110 |
+
confidence = 96.0 if power_w < noise_floor_w else max(40.0, 90.0 - (received_len / max(seq_len, 1)) * 50.0)
|
| 1111 |
+
|
| 1112 |
+
return {
|
| 1113 |
+
"success": True,
|
| 1114 |
+
"label": "idle",
|
| 1115 |
+
"confidence": round(confidence, 1),
|
| 1116 |
+
"index": 0,
|
| 1117 |
+
"model_version": meta.get("model_name") or "unknown_model",
|
| 1118 |
+
"label_source": label_source,
|
| 1119 |
+
"timestamp": _now_iso(),
|
| 1120 |
+
"problem_type": meta.get("problem_type") or "multilabel",
|
| 1121 |
+
"active_devices": [],
|
| 1122 |
+
"device_probs": [
|
| 1123 |
+
{"device": device, "probability": 0.0}
|
| 1124 |
+
for device in devices
|
| 1125 |
+
if isinstance(device, str) and device.strip()
|
| 1126 |
+
],
|
| 1127 |
+
"buffer": {
|
| 1128 |
+
"received": received_len,
|
| 1129 |
+
"window": seq_len,
|
| 1130 |
+
"status": buffer_status,
|
| 1131 |
+
"bar": _format_buffer_bar(received_len, seq_len),
|
| 1132 |
+
},
|
| 1133 |
+
"raw_top": {"label": "idle", "confidence": round(confidence, 1), "index": 0},
|
| 1134 |
+
"raw_second": {"label": "idle", "confidence": round(confidence, 1), "index": 0},
|
| 1135 |
+
}
|
| 1136 |
+
|
| 1137 |
+
|
| 1138 |
+
def _extract_features_from_sample(sample: dict):
|
| 1139 |
+
global _LAST_RAW_SAMPLE
|
| 1140 |
+
if not isinstance(sample, dict):
|
| 1141 |
+
raise ValueError("sample harus object")
|
| 1142 |
+
|
| 1143 |
+
v = _as_float(sample.get("voltage", sample.get("V")))
|
| 1144 |
+
i = _as_float(sample.get("current", sample.get("I")))
|
| 1145 |
+
p = _as_float(sample.get("power", sample.get("P")))
|
| 1146 |
+
pf = _as_float(sample.get("power_factor", sample.get("PF")))
|
| 1147 |
+
hz = _as_float(sample.get("frequency", sample.get("Hz")))
|
| 1148 |
+
|
| 1149 |
+
# Sinkron dengan final_pipeline (8).ipynb v6:
|
| 1150 |
+
# ['voltage', 'current', 'power', 'power_factor', 'frequency',
|
| 1151 |
+
# 'apparent_power', 'reactive_power', 'power_ratio']
|
| 1152 |
+
pf = max(0.0, min(pf, 1.0))
|
| 1153 |
+
apparent_power = v * i
|
| 1154 |
+
reactive_power = apparent_power * np.sqrt(max(0.0, 1.0 - (pf ** 2)))
|
| 1155 |
+
power_ratio = p / (apparent_power + 1e-6)
|
| 1156 |
+
|
| 1157 |
+
_LAST_RAW_SAMPLE = sample
|
| 1158 |
+
return np.array(
|
| 1159 |
+
[v, i, p, pf, hz, apparent_power, reactive_power, power_ratio],
|
| 1160 |
+
dtype=np.float32,
|
| 1161 |
+
)
|
| 1162 |
+
|
| 1163 |
+
|
| 1164 |
+
def _load_dummy_samples():
|
| 1165 |
+
if not _DUMMY_FILE.exists():
|
| 1166 |
+
raise FileNotFoundError(f"Dummy file tidak ditemukan: {_DUMMY_FILE}")
|
| 1167 |
+
|
| 1168 |
+
raw = _DUMMY_FILE.read_text(encoding="utf-8").strip()
|
| 1169 |
+
if not raw:
|
| 1170 |
+
raise ValueError("Dummy file kosong")
|
| 1171 |
+
|
| 1172 |
+
if raw.startswith("["):
|
| 1173 |
+
samples = json.loads(raw)
|
| 1174 |
+
else:
|
| 1175 |
+
samples = [json.loads(line) for line in raw.splitlines() if line.strip()]
|
| 1176 |
+
|
| 1177 |
+
if not isinstance(samples, list) or not samples:
|
| 1178 |
+
raise ValueError("Format dummy harus array JSON atau JSONL (per baris)")
|
| 1179 |
+
|
| 1180 |
+
for item in samples:
|
| 1181 |
+
if not isinstance(item, dict):
|
| 1182 |
+
raise ValueError("Setiap item dummy harus object")
|
| 1183 |
+
|
| 1184 |
+
return samples
|
| 1185 |
+
|
| 1186 |
+
|
| 1187 |
+
def _run_samples(samples: list[dict], payload: dict | None):
|
| 1188 |
+
stride = int((payload or {}).get("stride", 1))
|
| 1189 |
+
stride = 1 if stride < 1 else stride
|
| 1190 |
+
update_blynk = bool((payload or {}).get("update_blynk", False))
|
| 1191 |
+
|
| 1192 |
+
with _LOCK:
|
| 1193 |
+
_ensure_runtime_state()
|
| 1194 |
+
_SEQ_BUFFER.clear()
|
| 1195 |
+
global _EMA_PROBS, _LAST_RAW_SAMPLE
|
| 1196 |
+
_EMA_PROBS = None
|
| 1197 |
+
_PRED_QUEUE.clear()
|
| 1198 |
+
_PRED_DEVICE_QUEUE.clear()
|
| 1199 |
+
_LAST_RAW_SAMPLE = None
|
| 1200 |
+
|
| 1201 |
+
timeline = []
|
| 1202 |
+
last = None
|
| 1203 |
+
|
| 1204 |
+
for index, sample in enumerate(samples, start=1):
|
| 1205 |
+
with _LOCK:
|
| 1206 |
+
_ensure_runtime_state()
|
| 1207 |
+
if _should_reset_buffer_for_device_change(sample):
|
| 1208 |
+
_SEQ_BUFFER.clear()
|
| 1209 |
+
_EMA_PROBS = None
|
| 1210 |
+
_PRED_QUEUE.clear()
|
| 1211 |
+
_PRED_DEVICE_QUEUE.clear()
|
| 1212 |
+
|
| 1213 |
+
features = _extract_features_from_sample(sample)
|
| 1214 |
+
_SEQ_BUFFER.append(features.tolist())
|
| 1215 |
+
sequence, received_len = _parse_sequence({"sequence": list(_SEQ_BUFFER)})
|
| 1216 |
+
|
| 1217 |
+
result = _predict_from_sequence(sequence, received_len, payload)
|
| 1218 |
+
last = result
|
| 1219 |
+
|
| 1220 |
+
if update_blynk:
|
| 1221 |
+
meta = _read_model_meta()
|
| 1222 |
+
_blynk_update("V6", result["label"])
|
| 1223 |
+
_blynk_update("V7", result["confidence"])
|
| 1224 |
+
_blynk_update("V8", meta.get("model_name") or "N/A")
|
| 1225 |
+
_blynk_update("V9", _now_iso())
|
| 1226 |
+
|
| 1227 |
+
if index == 1 or index == len(samples) or index % stride == 0:
|
| 1228 |
+
timeline.append(
|
| 1229 |
+
{
|
| 1230 |
+
"step": index,
|
| 1231 |
+
"buffer": result["buffer"],
|
| 1232 |
+
"label": result["label"],
|
| 1233 |
+
"confidence": result["confidence"],
|
| 1234 |
+
},
|
| 1235 |
+
)
|
| 1236 |
+
|
| 1237 |
+
if last is None:
|
| 1238 |
+
raise ValueError("Tidak ada sample untuk diproses")
|
| 1239 |
+
|
| 1240 |
+
return last, timeline
|
| 1241 |
+
|
| 1242 |
+
|
| 1243 |
+
def _predict_from_sequence(sequence: np.ndarray, received_len: int, payload: dict | None):
|
| 1244 |
+
global _EMA_PROBS, _REQUEST_COUNT
|
| 1245 |
+
_REQUEST_COUNT += 1
|
| 1246 |
+
|
| 1247 |
+
_ensure_runtime_state()
|
| 1248 |
+
labels = _load_labels()
|
| 1249 |
+
label_source = _get_label_source()
|
| 1250 |
+
model = _get_model()
|
| 1251 |
+
meta = _read_model_meta()
|
| 1252 |
+
|
| 1253 |
+
input_shape = meta.get("input_shape") or []
|
| 1254 |
+
seq_len = int(input_shape[0]) if len(input_shape) >= 2 and isinstance(input_shape[0], int) else 99
|
| 1255 |
+
sequence = _scale_sequence(sequence, meta)
|
| 1256 |
+
probs = model.predict(sequence.reshape((1, seq_len, 8)), verbose=0)
|
| 1257 |
+
probs = np.array(probs).reshape((-1,))
|
| 1258 |
+
|
| 1259 |
+
if probs.size != len(labels):
|
| 1260 |
+
raise RuntimeError(f"Output model {probs.size} tidak cocok dengan labels {len(labels)}")
|
| 1261 |
+
|
| 1262 |
+
smoothing = str((payload or {}).get("smoothing", "ema")).lower()
|
| 1263 |
+
alpha = None
|
| 1264 |
+
if (meta.get("problem_type") or "multiclass") != "multilabel" and smoothing == "ema":
|
| 1265 |
+
alpha = float((payload or {}).get("ema_alpha", 0.6))
|
| 1266 |
+
probs = _apply_smoothing(probs, alpha)
|
| 1267 |
+
|
| 1268 |
+
top_index = int(np.argmax(probs))
|
| 1269 |
+
top_label = labels[top_index]
|
| 1270 |
+
top_confidence = float(probs[top_index]) * 100.0
|
| 1271 |
+
|
| 1272 |
+
sorted_indices = list(np.argsort(-probs))
|
| 1273 |
+
top3_indices = sorted_indices[:3]
|
| 1274 |
+
second_index = int(top3_indices[1]) if len(top3_indices) > 1 else top_index
|
| 1275 |
+
second_label = labels[second_index]
|
| 1276 |
+
second_confidence = float(probs[second_index]) * 100.0
|
| 1277 |
+
third_index = int(top3_indices[2]) if len(top3_indices) > 2 else top_index
|
| 1278 |
+
third_label = labels[third_index]
|
| 1279 |
+
third_confidence = float(probs[third_index]) * 100.0
|
| 1280 |
+
|
| 1281 |
+
problem_type = meta.get("problem_type") or "multiclass"
|
| 1282 |
+
power_w = _power_from_sample(_LAST_RAW_SAMPLE)
|
| 1283 |
+
noise_floor_w = float(meta.get("noise_floor_w") or 3.0)
|
| 1284 |
+
device_probs = _device_probs_payload(labels, probs, meta.get("devices"))
|
| 1285 |
+
|
| 1286 |
+
if power_w < noise_floor_w:
|
| 1287 |
+
buffer_status = "READY" if received_len >= seq_len else "LOADING"
|
| 1288 |
+
buffer_bar = _format_buffer_bar(received_len, seq_len)
|
| 1289 |
+
return {
|
| 1290 |
+
"success": True,
|
| 1291 |
+
"label": "idle",
|
| 1292 |
+
"confidence": round(max(0.0, 1.0 - float(np.max(probs))) * 100.0, 1),
|
| 1293 |
+
"index": top_index,
|
| 1294 |
+
"model_version": meta.get("model_name") or "unknown_model",
|
| 1295 |
+
"label_source": label_source,
|
| 1296 |
+
"timestamp": _now_iso(),
|
| 1297 |
+
"problem_type": problem_type,
|
| 1298 |
+
"active_devices": [],
|
| 1299 |
+
"device_probs": device_probs,
|
| 1300 |
+
"buffer": {
|
| 1301 |
+
"received": received_len,
|
| 1302 |
+
"window": seq_len,
|
| 1303 |
+
"status": buffer_status,
|
| 1304 |
+
"bar": buffer_bar,
|
| 1305 |
+
},
|
| 1306 |
+
"raw_top": {
|
| 1307 |
+
"label": top_label,
|
| 1308 |
+
"confidence": round(top_confidence, 1),
|
| 1309 |
+
"index": top_index,
|
| 1310 |
+
},
|
| 1311 |
+
"raw_second": {
|
| 1312 |
+
"label": second_label,
|
| 1313 |
+
"confidence": round(second_confidence, 1),
|
| 1314 |
+
"index": second_index,
|
| 1315 |
+
},
|
| 1316 |
+
}
|
| 1317 |
+
|
| 1318 |
+
if problem_type == "multilabel":
|
| 1319 |
+
devices = meta.get("devices") or labels
|
| 1320 |
+
|
| 1321 |
+
active_labels = _finalize_multilabel_devices(probs, labels, devices, meta, power_w)
|
| 1322 |
+
raw_label = _resolve_multilabel_label(active_labels, meta)
|
| 1323 |
+
|
| 1324 |
+
_PRED_DEVICE_QUEUE.append(frozenset(active_labels))
|
| 1325 |
+
chosen_active_devices = _majority_vote_active_devices(devices)
|
| 1326 |
+
if not chosen_active_devices and active_labels:
|
| 1327 |
+
chosen_active_devices = active_labels
|
| 1328 |
+
chosen_label = _resolve_multilabel_label(chosen_active_devices, meta)
|
| 1329 |
+
if chosen_active_devices:
|
| 1330 |
+
chosen_indices = [labels.index(device) for device in chosen_active_devices if device in labels]
|
| 1331 |
+
chosen_confidence = float(np.mean([float(probs[index]) for index in chosen_indices])) * 100.0
|
| 1332 |
+
else:
|
| 1333 |
+
chosen_confidence = max(0.0, 1.0 - float(np.max(probs))) * 100.0
|
| 1334 |
+
|
| 1335 |
+
if (
|
| 1336 |
+
chosen_active_devices == ["charger_hp"]
|
| 1337 |
+
and _solo_power_range_fits("charger_hp", power_w, meta.get("power_range") or {})
|
| 1338 |
+
and chosen_confidence < 45.0
|
| 1339 |
+
):
|
| 1340 |
+
chosen_confidence = max(chosen_confidence, 72.0)
|
| 1341 |
+
|
| 1342 |
+
chosen_index = top_index
|
| 1343 |
+
smoothing = f"device_vote:{_PRED_DEVICE_QUEUE.maxlen}"
|
| 1344 |
+
active_devices = chosen_active_devices
|
| 1345 |
+
else:
|
| 1346 |
+
prefer_non_uncertain = bool((payload or {}).get("prefer_non_uncertain", True))
|
| 1347 |
+
uncertain_label = str((payload or {}).get("uncertain_label", "uncertain"))
|
| 1348 |
+
min_second_confidence = float((payload or {}).get("min_second_confidence", 25.0))
|
| 1349 |
+
|
| 1350 |
+
chosen_index = top_index
|
| 1351 |
+
chosen_label = top_label
|
| 1352 |
+
chosen_confidence = top_confidence
|
| 1353 |
+
|
| 1354 |
+
if prefer_non_uncertain and top_label == uncertain_label and second_label != uncertain_label and second_confidence >= min_second_confidence:
|
| 1355 |
+
chosen_index = second_index
|
| 1356 |
+
chosen_label = second_label
|
| 1357 |
+
chosen_confidence = second_confidence
|
| 1358 |
+
|
| 1359 |
+
power_range = meta.get("power_range") or {}
|
| 1360 |
+
if chosen_label not in ("uncertain", "idle"):
|
| 1361 |
+
label_range = power_range.get(chosen_label)
|
| 1362 |
+
if label_range and not (label_range[0] <= power_w <= label_range[1] * 1.2):
|
| 1363 |
+
for alt_index in top3_indices[1:]:
|
| 1364 |
+
alt_label = labels[alt_index]
|
| 1365 |
+
alt_range = power_range.get(alt_label)
|
| 1366 |
+
alt_confidence = float(probs[alt_index]) * 100.0
|
| 1367 |
+
if alt_range and alt_range[0] <= power_w <= alt_range[1] * 1.2:
|
| 1368 |
+
chosen_index = alt_index
|
| 1369 |
+
chosen_label = alt_label
|
| 1370 |
+
chosen_confidence = alt_confidence
|
| 1371 |
+
break
|
| 1372 |
+
|
| 1373 |
+
active_devices = _active_devices_from_label(chosen_label, labels)
|
| 1374 |
+
|
| 1375 |
+
buffer_status = "READY" if received_len >= seq_len else "LOADING"
|
| 1376 |
+
min_buf = max(10, seq_len // 3)
|
| 1377 |
+
if received_len < min_buf:
|
| 1378 |
+
buffer_status = "WARMING"
|
| 1379 |
+
|
| 1380 |
+
if problem_type != "multilabel":
|
| 1381 |
+
active_devices = _active_devices_from_label(chosen_label, labels)
|
| 1382 |
+
buffer_bar = _format_buffer_bar(received_len, seq_len)
|
| 1383 |
+
print(
|
| 1384 |
+
f"[{_REQUEST_COUNT:05d}] Buffer {received_len}/{seq_len} {buffer_bar} {buffer_status} | "
|
| 1385 |
+
f"Detected {chosen_label} ({chosen_confidence:.1f}%) | "
|
| 1386 |
+
f"Top {top_label} ({top_confidence:.1f}%) | "
|
| 1387 |
+
f"smoothing={smoothing}{'' if alpha is None else f' alpha={alpha:.2f}'}"
|
| 1388 |
+
)
|
| 1389 |
+
|
| 1390 |
+
return {
|
| 1391 |
+
"success": True,
|
| 1392 |
+
"label": chosen_label,
|
| 1393 |
+
"confidence": round(chosen_confidence, 1),
|
| 1394 |
+
"index": chosen_index,
|
| 1395 |
+
"model_version": meta.get("model_name") or "unknown_model",
|
| 1396 |
+
"label_source": label_source,
|
| 1397 |
+
"timestamp": _now_iso(),
|
| 1398 |
+
"problem_type": problem_type,
|
| 1399 |
+
"active_devices": active_devices,
|
| 1400 |
+
"device_probs": device_probs,
|
| 1401 |
+
"buffer": {
|
| 1402 |
+
"received": received_len,
|
| 1403 |
+
"window": seq_len,
|
| 1404 |
+
"status": buffer_status,
|
| 1405 |
+
"bar": buffer_bar,
|
| 1406 |
+
},
|
| 1407 |
+
"raw_top": {
|
| 1408 |
+
"label": top_label,
|
| 1409 |
+
"confidence": round(top_confidence, 1),
|
| 1410 |
+
"index": top_index,
|
| 1411 |
+
},
|
| 1412 |
+
"raw_second": {
|
| 1413 |
+
"label": second_label,
|
| 1414 |
+
"confidence": round(second_confidence, 1),
|
| 1415 |
+
"index": second_index,
|
| 1416 |
+
},
|
| 1417 |
+
}
|
| 1418 |
+
|
| 1419 |
+
|
| 1420 |
+
|
| 1421 |
+
@app.get("/health")
|
| 1422 |
+
def health():
|
| 1423 |
+
meta = _read_model_meta()
|
| 1424 |
+
return jsonify(
|
| 1425 |
+
{
|
| 1426 |
+
"success": True,
|
| 1427 |
+
"model_dir": str(MODEL_DIR),
|
| 1428 |
+
"model_version": meta.get("model_name"),
|
| 1429 |
+
"problem_type": meta.get("problem_type"),
|
| 1430 |
+
"devices": meta.get("devices"),
|
| 1431 |
+
"files": _get_model_files(),
|
| 1432 |
+
}
|
| 1433 |
+
)
|
| 1434 |
+
|
| 1435 |
+
|
| 1436 |
+
@app.get("/")
|
| 1437 |
+
def index():
|
| 1438 |
+
return jsonify(
|
| 1439 |
+
{
|
| 1440 |
+
"success": True,
|
| 1441 |
+
"message": "NILM ML service aktif",
|
| 1442 |
+
"model_dir": str(MODEL_DIR),
|
| 1443 |
+
"endpoints": [
|
| 1444 |
+
"/health",
|
| 1445 |
+
"/labels",
|
| 1446 |
+
"/model/files",
|
| 1447 |
+
"/model/files/config.json",
|
| 1448 |
+
"/model/files/metadata.json",
|
| 1449 |
+
"/latest",
|
| 1450 |
+
"/dashboard/latest",
|
| 1451 |
+
"/predict",
|
| 1452 |
+
"/ingest",
|
| 1453 |
+
"/thingsboard/ingest",
|
| 1454 |
+
"/reset",
|
| 1455 |
+
"/demo/dummy",
|
| 1456 |
+
],
|
| 1457 |
+
}
|
| 1458 |
+
)
|
| 1459 |
+
|
| 1460 |
+
|
| 1461 |
+
@app.get("/model/files")
|
| 1462 |
+
def model_files():
|
| 1463 |
+
meta = _read_model_meta()
|
| 1464 |
+
return jsonify(
|
| 1465 |
+
{
|
| 1466 |
+
"success": True,
|
| 1467 |
+
"model_dir": str(MODEL_DIR),
|
| 1468 |
+
"model_name": meta.get("model_name"),
|
| 1469 |
+
"files": _get_model_files(),
|
| 1470 |
+
}
|
| 1471 |
+
)
|
| 1472 |
+
|
| 1473 |
+
|
| 1474 |
+
@app.get("/model/files/<path:name>")
|
| 1475 |
+
def model_file_content(name: str):
|
| 1476 |
+
try:
|
| 1477 |
+
path = _resolve_model_file(name)
|
| 1478 |
+
except ValueError as exc:
|
| 1479 |
+
return jsonify({"success": False, "error": str(exc)}), 400
|
| 1480 |
+
|
| 1481 |
+
if not path.exists():
|
| 1482 |
+
return jsonify({"success": False, "error": f"File tidak ditemukan: {path.name}"}), 404
|
| 1483 |
+
|
| 1484 |
+
if path.name in _MODEL_BINARY_FILES:
|
| 1485 |
+
return jsonify(
|
| 1486 |
+
{
|
| 1487 |
+
"success": True,
|
| 1488 |
+
"name": path.name,
|
| 1489 |
+
"path": str(path),
|
| 1490 |
+
"type": "binary",
|
| 1491 |
+
"size_bytes": path.stat().st_size,
|
| 1492 |
+
"content": None,
|
| 1493 |
+
"note": "File biner tidak ditampilkan, hanya metadata file.",
|
| 1494 |
+
}
|
| 1495 |
+
)
|
| 1496 |
+
|
| 1497 |
+
return jsonify(
|
| 1498 |
+
{
|
| 1499 |
+
"success": True,
|
| 1500 |
+
"name": path.name,
|
| 1501 |
+
"path": str(path),
|
| 1502 |
+
"type": "text",
|
| 1503 |
+
"size_bytes": path.stat().st_size,
|
| 1504 |
+
"content": _read_text(path),
|
| 1505 |
+
}
|
| 1506 |
+
)
|
| 1507 |
+
|
| 1508 |
+
@app.get("/labels")
|
| 1509 |
+
def labels():
|
| 1510 |
+
meta = _read_model_meta()
|
| 1511 |
+
runtime_labels = _load_labels()
|
| 1512 |
+
label_source = _get_label_source()
|
| 1513 |
+
labels_path = MODEL_DIR / "labels.json"
|
| 1514 |
+
configured_labels = None
|
| 1515 |
+
meta_path = _model_root() / "meta_nilm.json"
|
| 1516 |
+
session_to_label = None
|
| 1517 |
+
device_display = None
|
| 1518 |
+
|
| 1519 |
+
if meta_path.exists():
|
| 1520 |
+
raw_meta = _read_json(meta_path)
|
| 1521 |
+
session_to_label = raw_meta.get("session_to_label")
|
| 1522 |
+
device_display = raw_meta.get("device_display")
|
| 1523 |
+
|
| 1524 |
+
if labels_path.exists():
|
| 1525 |
+
configured_labels = _read_json(labels_path).get("labels", [])
|
| 1526 |
+
if not isinstance(configured_labels, list):
|
| 1527 |
+
configured_labels = []
|
| 1528 |
+
|
| 1529 |
+
visible_labels = [item.strip() for item in (configured_labels or runtime_labels) if isinstance(item, str) and item.strip()]
|
| 1530 |
+
placeholders = [label for label in runtime_labels if label.startswith("unknown_")]
|
| 1531 |
+
return jsonify(
|
| 1532 |
+
{
|
| 1533 |
+
"success": True,
|
| 1534 |
+
"model_dir": str(MODEL_DIR),
|
| 1535 |
+
"model_name": meta.get("model_name"),
|
| 1536 |
+
"output_units": meta.get("output_units"),
|
| 1537 |
+
"problem_type": meta.get("problem_type"),
|
| 1538 |
+
"devices": meta.get("devices"),
|
| 1539 |
+
"labels": visible_labels,
|
| 1540 |
+
"session_to_label": session_to_label,
|
| 1541 |
+
"device_display": device_display,
|
| 1542 |
+
"label_source": label_source,
|
| 1543 |
+
"has_placeholders": len(placeholders) > 0,
|
| 1544 |
+
"placeholders": placeholders,
|
| 1545 |
+
"configured_label_count": len(visible_labels),
|
| 1546 |
+
"runtime_label_count": len(runtime_labels),
|
| 1547 |
+
},
|
| 1548 |
+
)
|
| 1549 |
+
|
| 1550 |
+
|
| 1551 |
+
@app.get("/latest")
|
| 1552 |
+
def latest():
|
| 1553 |
+
if _LATEST_RESULT is None:
|
| 1554 |
+
return jsonify({"success": False, "error": "Belum ada data telemetry yang masuk ke ML service."}), 404
|
| 1555 |
+
return jsonify(_LATEST_RESULT)
|
| 1556 |
+
|
| 1557 |
+
|
| 1558 |
+
@app.get("/dashboard/latest")
|
| 1559 |
+
def dashboard_latest():
|
| 1560 |
+
"""Pipeline untuk GitHub Pages: ThingsBoard → inferensi → JSON dashboard."""
|
| 1561 |
+
global _LATEST_RESULT
|
| 1562 |
+
|
| 1563 |
+
if str(os.environ.get("USE_DUMMY_BLYNK", "")).lower() in ("1", "true", "yes"):
|
| 1564 |
+
samples = _load_dummy_samples()
|
| 1565 |
+
if not samples:
|
| 1566 |
+
return jsonify({"success": False, "error": "Dummy samples tidak tersedia."}), 503
|
| 1567 |
+
sample = _normalize_sample(samples[-1])
|
| 1568 |
+
source = "dummy"
|
| 1569 |
+
else:
|
| 1570 |
+
try:
|
| 1571 |
+
from thingsboard_client import fetch_thingsboard_sample
|
| 1572 |
+
|
| 1573 |
+
sample = _normalize_sample(fetch_thingsboard_sample())
|
| 1574 |
+
source = "thingsboard"
|
| 1575 |
+
except Exception as exc:
|
| 1576 |
+
return jsonify(
|
| 1577 |
+
{
|
| 1578 |
+
"success": False,
|
| 1579 |
+
"data": None,
|
| 1580 |
+
"source": "thingsboard",
|
| 1581 |
+
"last_updated": _now_iso(),
|
| 1582 |
+
"error": f"ThingsBoard connection error: {exc}",
|
| 1583 |
+
}
|
| 1584 |
+
), 502
|
| 1585 |
+
|
| 1586 |
+
try:
|
| 1587 |
+
with _LOCK:
|
| 1588 |
+
pred = _get_v9_predictor().predict(sample)
|
| 1589 |
+
response_payload = _predictor_to_response(pred, sample)
|
| 1590 |
+
except Exception as exc:
|
| 1591 |
+
return jsonify(
|
| 1592 |
+
{
|
| 1593 |
+
"success": False,
|
| 1594 |
+
"data": None,
|
| 1595 |
+
"source": source,
|
| 1596 |
+
"last_updated": _now_iso(),
|
| 1597 |
+
"error": f"ML inference error: {exc}",
|
| 1598 |
+
}
|
| 1599 |
+
), 500
|
| 1600 |
+
|
| 1601 |
+
built = _build_latest_result(sample, response_payload)
|
| 1602 |
+
_LATEST_RESULT = built
|
| 1603 |
+
data = built["data"]
|
| 1604 |
+
return jsonify(
|
| 1605 |
+
{
|
| 1606 |
+
"success": True,
|
| 1607 |
+
"data": data,
|
| 1608 |
+
"source": source,
|
| 1609 |
+
"last_updated": data.get("timestamp") or _now_iso(),
|
| 1610 |
+
"error": None,
|
| 1611 |
+
"meta": built.get("meta"),
|
| 1612 |
+
}
|
| 1613 |
+
)
|
| 1614 |
+
|
| 1615 |
+
|
| 1616 |
+
@app.post("/predict")
|
| 1617 |
+
def predict():
|
| 1618 |
+
payload = request.get_json(silent=True)
|
| 1619 |
+
sequence, received_len = _parse_sequence(payload)
|
| 1620 |
+
try:
|
| 1621 |
+
response_payload = _predict_from_sequence(sequence, received_len, payload)
|
| 1622 |
+
except Exception as exc:
|
| 1623 |
+
return jsonify({"success": False, "error": str(exc)}), 500
|
| 1624 |
+
|
| 1625 |
+
update_blynk = bool((payload or {}).get("update_blynk", False))
|
| 1626 |
+
blynk_result = None
|
| 1627 |
+
if update_blynk:
|
| 1628 |
+
meta = _read_model_meta()
|
| 1629 |
+
try:
|
| 1630 |
+
_blynk_update("V6", response_payload["label"])
|
| 1631 |
+
_blynk_update("V7", response_payload["confidence"])
|
| 1632 |
+
_blynk_update("V8", meta.get("model_name") or "N/A")
|
| 1633 |
+
_blynk_update("V9", _now_iso())
|
| 1634 |
+
blynk_result = {"updated": True, "pins": ["V6", "V7", "V8", "V9"]}
|
| 1635 |
+
except Exception as exc:
|
| 1636 |
+
blynk_result = {"updated": False, "error": str(exc)}
|
| 1637 |
+
|
| 1638 |
+
response_payload["blynk"] = blynk_result
|
| 1639 |
+
return jsonify(_make_json_safe(response_payload))
|
| 1640 |
+
|
| 1641 |
+
|
| 1642 |
+
@app.post("/ingest")
|
| 1643 |
+
def ingest():
|
| 1644 |
+
global _LATEST_RESULT
|
| 1645 |
+
payload = request.get_json(silent=True) or {}
|
| 1646 |
+
sample = _normalize_sample(payload)
|
| 1647 |
+
|
| 1648 |
+
try:
|
| 1649 |
+
with _LOCK:
|
| 1650 |
+
pred = _get_v9_predictor().predict(sample)
|
| 1651 |
+
response_payload = _predictor_to_response(pred, sample)
|
| 1652 |
+
except Exception as exc:
|
| 1653 |
+
return jsonify({"success": False, "error": str(exc)}), 500
|
| 1654 |
+
|
| 1655 |
+
update_blynk = bool(payload.get("update_blynk", False))
|
| 1656 |
+
blynk_result = None
|
| 1657 |
+
if update_blynk:
|
| 1658 |
+
meta = _read_model_meta()
|
| 1659 |
+
try:
|
| 1660 |
+
_blynk_update("V6", response_payload["label"])
|
| 1661 |
+
_blynk_update("V7", response_payload["confidence"])
|
| 1662 |
+
_blynk_update("V8", meta.get("model_name") or "N/A")
|
| 1663 |
+
_blynk_update("V9", _now_iso())
|
| 1664 |
+
blynk_result = {"updated": True, "pins": ["V6", "V7", "V8", "V9"]}
|
| 1665 |
+
except Exception as exc:
|
| 1666 |
+
blynk_result = {"updated": False, "error": str(exc)}
|
| 1667 |
+
|
| 1668 |
+
_LATEST_RESULT = _build_latest_result(sample, response_payload)
|
| 1669 |
+
response_payload["blynk"] = blynk_result
|
| 1670 |
+
response_payload["sample"] = _sample_to_dict(sample)
|
| 1671 |
+
return jsonify(_make_json_safe(response_payload))
|
| 1672 |
+
|
| 1673 |
+
|
| 1674 |
+
@app.post("/thingsboard/ingest")
|
| 1675 |
+
def thingsboard_ingest():
|
| 1676 |
+
return ingest()
|
| 1677 |
+
|
| 1678 |
+
|
| 1679 |
+
@app.post("/reset")
|
| 1680 |
+
def reset():
|
| 1681 |
+
global _EMA_PROBS, _LAST_RAW_SAMPLE, _PREV_POWER, _LATEST_RESULT, _V9_PREDICTOR
|
| 1682 |
+
with _LOCK:
|
| 1683 |
+
_ensure_runtime_state()
|
| 1684 |
+
_SEQ_BUFFER.clear()
|
| 1685 |
+
_PRED_QUEUE.clear()
|
| 1686 |
+
_PRED_DEVICE_QUEUE.clear()
|
| 1687 |
+
_EMA_PROBS = None
|
| 1688 |
+
_LAST_RAW_SAMPLE = None
|
| 1689 |
+
_PREV_POWER = None
|
| 1690 |
+
_LATEST_RESULT = None
|
| 1691 |
+
if _V9_PREDICTOR is not None:
|
| 1692 |
+
_V9_PREDICTOR.reset()
|
| 1693 |
+
return jsonify({"success": True})
|
| 1694 |
+
|
| 1695 |
+
|
| 1696 |
+
@app.get("/demo/dummy")
|
| 1697 |
+
def demo_dummy():
|
| 1698 |
+
payload = dict(request.args)
|
| 1699 |
+
payload["update_blynk"] = str(payload.get("update_blynk", "false")).lower() in ("1", "true", "yes")
|
| 1700 |
+
if "stride" in payload:
|
| 1701 |
+
try:
|
| 1702 |
+
payload["stride"] = int(payload["stride"])
|
| 1703 |
+
except Exception:
|
| 1704 |
+
payload["stride"] = 1
|
| 1705 |
+
|
| 1706 |
+
try:
|
| 1707 |
+
samples = _load_dummy_samples()
|
| 1708 |
+
last, timeline = _run_samples(samples, payload)
|
| 1709 |
+
except Exception as exc:
|
| 1710 |
+
return jsonify({"success": False, "error": str(exc)}), 500
|
| 1711 |
+
|
| 1712 |
+
return jsonify(
|
| 1713 |
+
{
|
| 1714 |
+
"success": True,
|
| 1715 |
+
"file": str(_DUMMY_FILE),
|
| 1716 |
+
"total_samples": len(samples),
|
| 1717 |
+
"result": {
|
| 1718 |
+
"label": last["label"],
|
| 1719 |
+
"confidence": last["confidence"],
|
| 1720 |
+
"buffer": last["buffer"],
|
| 1721 |
+
},
|
| 1722 |
+
"timeline": timeline,
|
| 1723 |
+
},
|
| 1724 |
+
)
|
| 1725 |
+
|
| 1726 |
+
|
| 1727 |
+
def _preload_model_on_startup():
|
| 1728 |
+
"""Muat model saat startup agar request pertama tidak terasa hang."""
|
| 1729 |
+
print("=" * 60)
|
| 1730 |
+
print("NILM ML Service")
|
| 1731 |
+
print(f" Model dir : {MODEL_DIR}")
|
| 1732 |
+
print(f" Model ada : {(MODEL_DIR / 'best_nilm_model.keras').exists()}")
|
| 1733 |
+
try:
|
| 1734 |
+
meta = _read_model_meta()
|
| 1735 |
+
print(f" Versi : {meta.get('model_name')}")
|
| 1736 |
+
print(f" Devices : {meta.get('devices')}")
|
| 1737 |
+
print(f" Window : {meta.get('input_shape')}")
|
| 1738 |
+
print(" Memuat TensorFlow (10–30 detik, tunggu)...")
|
| 1739 |
+
predictor = _get_v9_predictor()
|
| 1740 |
+
print(f" Predictor : {predictor.meta.get('model_version')}")
|
| 1741 |
+
print(" Model SIAP.")
|
| 1742 |
+
except Exception as exc:
|
| 1743 |
+
print(f" GAGAL muat model: {exc}")
|
| 1744 |
+
print(" Server tetap jalan; perbaiki model lalu restart.")
|
| 1745 |
+
print("=" * 60)
|
| 1746 |
+
|
| 1747 |
+
|
| 1748 |
+
if os.environ.get("NILM_PRELOAD_MODEL", "").lower() in ("1", "true", "yes"):
|
| 1749 |
+
_preload_model_on_startup()
|
| 1750 |
+
|
| 1751 |
+
|
| 1752 |
+
if __name__ == "__main__":
|
| 1753 |
+
port = int(os.environ.get("PORT", "5001"))
|
| 1754 |
+
_preload_model_on_startup()
|
| 1755 |
+
print(f"Server: http://127.0.0.1:{port} (CTRL+C untuk stop)")
|
| 1756 |
+
app.run(host="0.0.0.0", port=port, threaded=True)
|
ml_service/nilm_v9_predictor.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Predictor NILM v9 — selaras nilm_inference.py + perbaikan kipas/kombinasi.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from collections import deque
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Optional
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class MetaScaler:
|
| 16 |
+
def __init__(self, mean: list[float], scale: list[float]):
|
| 17 |
+
self.mean_ = np.array(mean, dtype=np.float32)
|
| 18 |
+
self.scale_ = np.array(
|
| 19 |
+
[float(item) if float(item) != 0.0 else 1.0 for item in scale],
|
| 20 |
+
dtype=np.float32,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
def transform(self, data: np.ndarray) -> np.ndarray:
|
| 24 |
+
return (data - self.mean_) / self.scale_
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def build_feature_vector(raw: dict) -> np.ndarray:
|
| 28 |
+
def _f(key: str, fallback: float = 0.0) -> float:
|
| 29 |
+
try:
|
| 30 |
+
value = float(raw[key])
|
| 31 |
+
return value if np.isfinite(value) else fallback
|
| 32 |
+
except Exception:
|
| 33 |
+
return fallback
|
| 34 |
+
|
| 35 |
+
voltage = _f("voltage", 220.0)
|
| 36 |
+
current = _f("current", 0.0)
|
| 37 |
+
power = _f("power", 0.0)
|
| 38 |
+
power_factor = max(0.0, min(_f("power_factor", 0.9), 1.0))
|
| 39 |
+
frequency = _f("frequency", 50.0)
|
| 40 |
+
|
| 41 |
+
apparent_power = voltage * current
|
| 42 |
+
reactive_power = apparent_power * np.sqrt(max(0.0, 1.0 - power_factor**2))
|
| 43 |
+
power_ratio = power / (apparent_power + 1e-6)
|
| 44 |
+
|
| 45 |
+
return np.array(
|
| 46 |
+
[voltage, current, power, power_factor, frequency, apparent_power, reactive_power, power_ratio],
|
| 47 |
+
dtype=np.float32,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class NilmV9Predictor:
|
| 52 |
+
def __init__(self, model_dir: Path):
|
| 53 |
+
import tensorflow as tf
|
| 54 |
+
|
| 55 |
+
model_dir = Path(model_dir).resolve()
|
| 56 |
+
meta_path = model_dir / "meta_nilm.json"
|
| 57 |
+
keras_path = model_dir / "best_nilm_model.keras"
|
| 58 |
+
|
| 59 |
+
if not meta_path.exists():
|
| 60 |
+
raise FileNotFoundError(f"meta_nilm.json tidak ditemukan di {model_dir}")
|
| 61 |
+
if not keras_path.exists():
|
| 62 |
+
raise FileNotFoundError(f"best_nilm_model.keras tidak ditemukan di {model_dir}")
|
| 63 |
+
|
| 64 |
+
with meta_path.open(encoding="utf-8") as handle:
|
| 65 |
+
self.meta: dict[str, Any] = json.load(handle)
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
register = tf.keras.saving.register_keras_serializable(package="nilm_v9")
|
| 69 |
+
except AttributeError:
|
| 70 |
+
register = tf.keras.utils.register_keras_serializable(package="nilm_v9")
|
| 71 |
+
|
| 72 |
+
@register
|
| 73 |
+
class TemporalSum(tf.keras.layers.Layer):
|
| 74 |
+
def call(self, inputs):
|
| 75 |
+
return tf.reduce_sum(inputs, axis=1)
|
| 76 |
+
|
| 77 |
+
def get_config(self):
|
| 78 |
+
return super().get_config()
|
| 79 |
+
|
| 80 |
+
def weighted_bce(y_true, y_pred):
|
| 81 |
+
return tf.reduce_mean(tf.keras.losses.binary_crossentropy(y_true, y_pred))
|
| 82 |
+
|
| 83 |
+
def exact_match(y_true, y_pred):
|
| 84 |
+
pred_bin = tf.cast(y_pred >= 0.5, tf.float32)
|
| 85 |
+
match = tf.reduce_all(tf.equal(pred_bin, y_true), axis=1)
|
| 86 |
+
return tf.reduce_mean(tf.cast(match, tf.float32))
|
| 87 |
+
|
| 88 |
+
self.model = tf.keras.models.load_model(
|
| 89 |
+
str(keras_path),
|
| 90 |
+
custom_objects={
|
| 91 |
+
"TemporalSum": TemporalSum,
|
| 92 |
+
"weighted_bce": weighted_bce,
|
| 93 |
+
"exact_match": exact_match,
|
| 94 |
+
},
|
| 95 |
+
compile=False,
|
| 96 |
+
safe_mode=False,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
self.scaler = MetaScaler(self.meta["scaler_mean"], self.meta["scaler_scale"])
|
| 100 |
+
self.window_size = int(self.meta["window_size"])
|
| 101 |
+
self.devices: list[str] = list(self.meta["devices"])
|
| 102 |
+
self.threshold = float(self.meta.get("threshold", 0.5))
|
| 103 |
+
self.device_thresholds: dict[str, float] = {
|
| 104 |
+
str(key): float(value)
|
| 105 |
+
for key, value in (self.meta.get("device_thresholds") or {}).items()
|
| 106 |
+
if isinstance(value, (int, float))
|
| 107 |
+
}
|
| 108 |
+
self.noise_floor_w = float(self.meta.get("noise_floor_w", 3.0))
|
| 109 |
+
self.smooth_n = max(1, int(self.meta.get("smooth_n", 5)))
|
| 110 |
+
self.transition_delta = float(self.meta.get("transition_delta", 30.0))
|
| 111 |
+
self.power_range: dict = dict(self.meta.get("power_range") or {})
|
| 112 |
+
self.session_to_label: dict[str, str] = {
|
| 113 |
+
key: str(value) for key, value in (self.meta.get("session_to_label") or {}).items()
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
self._buffer: deque = deque(maxlen=self.window_size)
|
| 117 |
+
self._label_vote_queue: deque = deque(maxlen=self.smooth_n)
|
| 118 |
+
self._prev_apparent: Optional[float] = None
|
| 119 |
+
|
| 120 |
+
def reset(self) -> None:
|
| 121 |
+
self._buffer.clear()
|
| 122 |
+
self._label_vote_queue.clear()
|
| 123 |
+
self._prev_apparent = None
|
| 124 |
+
|
| 125 |
+
def _median_power_w(self) -> float:
|
| 126 |
+
if not self._buffer:
|
| 127 |
+
return 0.0
|
| 128 |
+
powers = [float(row[2]) for row in self._buffer]
|
| 129 |
+
return float(np.median(powers))
|
| 130 |
+
|
| 131 |
+
def _device_threshold(self, device: str) -> float:
|
| 132 |
+
return self.device_thresholds.get(device, self.threshold)
|
| 133 |
+
|
| 134 |
+
def _prob_map(self, probs: np.ndarray) -> dict[str, float]:
|
| 135 |
+
return {device: float(probability) for device, probability in zip(self.devices, probs)}
|
| 136 |
+
|
| 137 |
+
def _active_from_label(self, label: Optional[str]) -> list[str]:
|
| 138 |
+
if not label or label == "idle":
|
| 139 |
+
return []
|
| 140 |
+
if label == "full_load":
|
| 141 |
+
return list(self.devices)
|
| 142 |
+
parts = {part.strip() for part in label.split("+") if part.strip()}
|
| 143 |
+
return [device for device in self.devices if device in parts]
|
| 144 |
+
|
| 145 |
+
def _labels_matching_power(self, power_w: float) -> list[tuple[float, str, int]]:
|
| 146 |
+
if power_w < self.noise_floor_w:
|
| 147 |
+
return [(0.0, "idle", 0)]
|
| 148 |
+
|
| 149 |
+
matches: list[tuple[float, str, int]] = []
|
| 150 |
+
for label, rng in self.power_range.items():
|
| 151 |
+
if label == "idle" or not isinstance(rng, list) or len(rng) < 2:
|
| 152 |
+
continue
|
| 153 |
+
lo, hi = float(rng[0]), float(rng[1]) * 1.12
|
| 154 |
+
if lo <= power_w <= hi:
|
| 155 |
+
center = (float(rng[0]) + float(rng[1])) / 2.0
|
| 156 |
+
device_count = len(self._active_from_label(label))
|
| 157 |
+
matches.append((abs(power_w - center), label, device_count))
|
| 158 |
+
|
| 159 |
+
# Jarak sama → prefer label lebih sederhana (mis. laptop vs laptop+kipas)
|
| 160 |
+
matches.sort(key=lambda item: (item[0], item[2]))
|
| 161 |
+
return matches
|
| 162 |
+
|
| 163 |
+
def _power_fingerprint_label(self, power_w: float) -> Optional[str]:
|
| 164 |
+
matches = self._labels_matching_power(power_w)
|
| 165 |
+
return matches[0][1] if matches else None
|
| 166 |
+
|
| 167 |
+
def _model_active_from_probs(self, probs: np.ndarray, power_w: float = 0.0) -> list[str]:
|
| 168 |
+
prob_map = self._prob_map(probs)
|
| 169 |
+
stable = self._median_power_w() or power_w
|
| 170 |
+
active: list[str] = []
|
| 171 |
+
for device in self.devices:
|
| 172 |
+
threshold = self._device_threshold(device)
|
| 173 |
+
# Di zona laptop, kipas butuh prob lebih tinggi agar tidak false-positive
|
| 174 |
+
if device == "kipas" and stable > 50:
|
| 175 |
+
threshold = max(threshold, 0.62)
|
| 176 |
+
if prob_map[device] >= threshold:
|
| 177 |
+
active.append(device)
|
| 178 |
+
return active
|
| 179 |
+
|
| 180 |
+
def _label_score(self, label: str, model_set: set[str], prob_map: dict[str, float]) -> float:
|
| 181 |
+
label_active = set(self._active_from_label(label))
|
| 182 |
+
if not label_active:
|
| 183 |
+
return 0.0
|
| 184 |
+
|
| 185 |
+
union = model_set | label_active
|
| 186 |
+
jaccard = len(model_set & label_active) / len(union) if union else 0.0
|
| 187 |
+
prob_score = float(np.mean([prob_map.get(device, 0.0) for device in label_active]))
|
| 188 |
+
return 0.55 * jaccard + 0.45 * prob_score
|
| 189 |
+
|
| 190 |
+
def _best_label_for_power(self, power_w: float, model_active: list[str], probs: np.ndarray) -> Optional[str]:
|
| 191 |
+
matches = self._labels_matching_power(power_w)
|
| 192 |
+
if not matches:
|
| 193 |
+
return None
|
| 194 |
+
|
| 195 |
+
model_set = set(model_active)
|
| 196 |
+
prob_map = self._prob_map(probs)
|
| 197 |
+
|
| 198 |
+
if model_set:
|
| 199 |
+
exact = [
|
| 200 |
+
label
|
| 201 |
+
for _, label, _ in matches
|
| 202 |
+
if set(self._active_from_label(label)) == model_set
|
| 203 |
+
]
|
| 204 |
+
if exact:
|
| 205 |
+
return exact[0]
|
| 206 |
+
|
| 207 |
+
best_label = None
|
| 208 |
+
best_score = -1.0
|
| 209 |
+
for distance, label, _ in matches[:10]:
|
| 210 |
+
score = self._label_score(label, model_set, prob_map) - (distance / 120.0)
|
| 211 |
+
if score > best_score:
|
| 212 |
+
best_score = score
|
| 213 |
+
best_label = label
|
| 214 |
+
|
| 215 |
+
return best_label
|
| 216 |
+
|
| 217 |
+
def _disambiguate_laptop_combo(
|
| 218 |
+
self,
|
| 219 |
+
power_w: float,
|
| 220 |
+
model_active: list[str],
|
| 221 |
+
probs: np.ndarray,
|
| 222 |
+
fallback_label: Optional[str],
|
| 223 |
+
) -> Optional[str]:
|
| 224 |
+
"""Pisahkan laptop+kipas vs laptop+charger saat daya laptop fluktuatif."""
|
| 225 |
+
prob_map = self._prob_map(probs)
|
| 226 |
+
model_set = set(model_active)
|
| 227 |
+
if "laptop" not in model_set:
|
| 228 |
+
return fallback_label
|
| 229 |
+
|
| 230 |
+
matches = self._labels_matching_power(power_w)
|
| 231 |
+
candidate_labels = [label for _, label, _ in matches]
|
| 232 |
+
laptop_combos = [
|
| 233 |
+
label
|
| 234 |
+
for label in candidate_labels
|
| 235 |
+
if label.startswith("laptop") and "hair_dryer" not in label
|
| 236 |
+
]
|
| 237 |
+
if not laptop_combos:
|
| 238 |
+
return fallback_label
|
| 239 |
+
|
| 240 |
+
kipas_p = prob_map.get("kipas", 0.0)
|
| 241 |
+
charger_p = prob_map.get("charger_hp", 0.0)
|
| 242 |
+
laptop_p = prob_map.get("laptop", 0.0)
|
| 243 |
+
|
| 244 |
+
# Model aktifkan keduanya → pilih sekunder dengan margin probabilitas
|
| 245 |
+
if "kipas" in model_set and "charger_hp" in model_set:
|
| 246 |
+
prefer_kipas = kipas_p >= charger_p + 0.08
|
| 247 |
+
prefer_charger = charger_p >= kipas_p + 0.08
|
| 248 |
+
for label in laptop_combos:
|
| 249 |
+
parts = set(self._active_from_label(label))
|
| 250 |
+
if prefer_kipas and parts == {"laptop", "kipas"}:
|
| 251 |
+
return label
|
| 252 |
+
if prefer_charger and parts == {"laptop", "charger_hp"}:
|
| 253 |
+
return label
|
| 254 |
+
if not prefer_kipas and not prefer_charger and parts == {"laptop", "kipas", "charger_hp"}:
|
| 255 |
+
return label
|
| 256 |
+
|
| 257 |
+
if "kipas" in model_set and "charger_hp" not in model_set:
|
| 258 |
+
for label in laptop_combos:
|
| 259 |
+
if set(self._active_from_label(label)) == {"laptop", "kipas"}:
|
| 260 |
+
return label
|
| 261 |
+
|
| 262 |
+
if "charger_hp" in model_set and "kipas" not in model_set:
|
| 263 |
+
for label in laptop_combos:
|
| 264 |
+
if set(self._active_from_label(label)) == {"laptop", "charger_hp"}:
|
| 265 |
+
return label
|
| 266 |
+
|
| 267 |
+
if laptop_p >= 0.5 and not model_set - {"laptop"}:
|
| 268 |
+
solo = [label for label in laptop_combos if label == "laptop"]
|
| 269 |
+
if solo:
|
| 270 |
+
return solo[0]
|
| 271 |
+
|
| 272 |
+
return fallback_label
|
| 273 |
+
|
| 274 |
+
def _refine_active_devices(
|
| 275 |
+
self,
|
| 276 |
+
model_active: list[str],
|
| 277 |
+
probs: np.ndarray,
|
| 278 |
+
power_w: float,
|
| 279 |
+
) -> list[str]:
|
| 280 |
+
if power_w < self.noise_floor_w:
|
| 281 |
+
return []
|
| 282 |
+
|
| 283 |
+
stable_power = self._median_power_w() or power_w
|
| 284 |
+
fingerprint_label = self._power_fingerprint_label(stable_power)
|
| 285 |
+
fingerprint_active = self._active_from_label(fingerprint_label)
|
| 286 |
+
|
| 287 |
+
# Charger: model sering salah prediksi kipas+laptop di daya rendah
|
| 288 |
+
if stable_power <= 18:
|
| 289 |
+
return fingerprint_active or ["charger_hp"]
|
| 290 |
+
|
| 291 |
+
# Kipas & kombinasi kipas (18–50 W): ikuti fingerprint daya stabil
|
| 292 |
+
if 18 < stable_power < 50:
|
| 293 |
+
if fingerprint_active:
|
| 294 |
+
return fingerprint_active
|
| 295 |
+
return model_active
|
| 296 |
+
|
| 297 |
+
# Laptop & kombinasi: skor model + median daya (daya laptop fluktuatif)
|
| 298 |
+
if 45 <= stable_power < 195:
|
| 299 |
+
best_label = self._best_label_for_power(stable_power, model_active, probs)
|
| 300 |
+
best_label = self._disambiguate_laptop_combo(stable_power, model_active, probs, best_label)
|
| 301 |
+
best_active = self._active_from_label(best_label)
|
| 302 |
+
if best_active:
|
| 303 |
+
return best_active
|
| 304 |
+
without_hair = [device for device in model_active if device != "hair_dryer"]
|
| 305 |
+
return without_hair if without_hair else model_active
|
| 306 |
+
|
| 307 |
+
if stable_power >= 195:
|
| 308 |
+
best_label = self._best_label_for_power(stable_power, model_active, probs)
|
| 309 |
+
best_active = self._active_from_label(best_label)
|
| 310 |
+
if best_active:
|
| 311 |
+
return best_active
|
| 312 |
+
return model_active
|
| 313 |
+
|
| 314 |
+
if fingerprint_active:
|
| 315 |
+
return fingerprint_active
|
| 316 |
+
|
| 317 |
+
return model_active
|
| 318 |
+
|
| 319 |
+
def _vote_label(self, label: str) -> str:
|
| 320 |
+
self._label_vote_queue.append(label)
|
| 321 |
+
if not self._label_vote_queue:
|
| 322 |
+
return label
|
| 323 |
+
return max(set(self._label_vote_queue), key=self._label_vote_queue.count)
|
| 324 |
+
|
| 325 |
+
def _canonical_label(self, active: list[str]) -> str:
|
| 326 |
+
if not active:
|
| 327 |
+
return "idle"
|
| 328 |
+
|
| 329 |
+
active_set = set(active)
|
| 330 |
+
for label in self.session_to_label.values():
|
| 331 |
+
if label == "idle":
|
| 332 |
+
continue
|
| 333 |
+
if label == "full_load" and active_set == set(self.devices):
|
| 334 |
+
return label
|
| 335 |
+
parts = {part.strip() for part in label.split("+") if part.strip()}
|
| 336 |
+
ordered = [device for device in self.devices if device in parts]
|
| 337 |
+
if set(ordered) == active_set:
|
| 338 |
+
return label
|
| 339 |
+
|
| 340 |
+
return "+".join(device for device in self.devices if device in active_set)
|
| 341 |
+
|
| 342 |
+
def _compute_detection_confidence(
|
| 343 |
+
self,
|
| 344 |
+
active_devices: list[str],
|
| 345 |
+
probs: np.ndarray,
|
| 346 |
+
buffer_status: str,
|
| 347 |
+
buffer_fill: int,
|
| 348 |
+
label: str,
|
| 349 |
+
power_w: float,
|
| 350 |
+
model_active: list[str],
|
| 351 |
+
) -> float:
|
| 352 |
+
prob_map = self._prob_map(probs)
|
| 353 |
+
min_buf = max(10, self.window_size // 3)
|
| 354 |
+
|
| 355 |
+
if buffer_status == "warming":
|
| 356 |
+
progress = min(1.0, buffer_fill / min_buf)
|
| 357 |
+
return round(18 + progress * 32, 1)
|
| 358 |
+
|
| 359 |
+
if not active_devices or label == "idle":
|
| 360 |
+
peak = max(prob_map.values()) if prob_map else 0.0
|
| 361 |
+
return round(max(88.0, (1.0 - peak) * 100.0), 1)
|
| 362 |
+
|
| 363 |
+
active_probs = [prob_map[device] for device in active_devices if device in prob_map]
|
| 364 |
+
if not active_probs:
|
| 365 |
+
return 78.0
|
| 366 |
+
|
| 367 |
+
max_percent = max(active_probs) * 100.0
|
| 368 |
+
min_percent = min(active_probs) * 100.0
|
| 369 |
+
model_set = set(model_active)
|
| 370 |
+
active_set = set(active_devices)
|
| 371 |
+
refined = active_set != model_set
|
| 372 |
+
|
| 373 |
+
if len(active_devices) == 1:
|
| 374 |
+
score = max_percent
|
| 375 |
+
if score < 50 and refined:
|
| 376 |
+
score = max(74.0, min(92.0, 68.0 + score * 0.35))
|
| 377 |
+
return round(min(99.0, score), 1)
|
| 378 |
+
|
| 379 |
+
# Kombinasi (termasuk kipas+X): harmonic blend — tidak hanya min rendah
|
| 380 |
+
if min_percent < 1.0:
|
| 381 |
+
min_percent = max_percent * 0.35
|
| 382 |
+
score = (2 * min_percent * max_percent) / (min_percent + max_percent + 1e-6)
|
| 383 |
+
|
| 384 |
+
kipas_percent = prob_map.get("kipas", 0.0) * 100.0
|
| 385 |
+
if "kipas" in active_set and kipas_percent >= 40:
|
| 386 |
+
score = max(score, kipas_percent * 0.85 + max_percent * 0.15)
|
| 387 |
+
|
| 388 |
+
if refined and score < 68:
|
| 389 |
+
score = max(70.0, 0.45 * min_percent + 0.55 * max_percent)
|
| 390 |
+
|
| 391 |
+
return round(min(99.0, score), 1)
|
| 392 |
+
|
| 393 |
+
def predict(self, raw: dict) -> dict:
|
| 394 |
+
feat = build_feature_vector(raw)
|
| 395 |
+
power_w = float(feat[2])
|
| 396 |
+
apparent_w = float(feat[5])
|
| 397 |
+
|
| 398 |
+
if self._prev_apparent is not None and abs(apparent_w - self._prev_apparent) > self.transition_delta:
|
| 399 |
+
self._buffer.clear()
|
| 400 |
+
self._label_vote_queue.clear()
|
| 401 |
+
self._prev_apparent = apparent_w
|
| 402 |
+
|
| 403 |
+
if power_w < self.noise_floor_w:
|
| 404 |
+
self.reset()
|
| 405 |
+
return self._pack("idle", [], [0.0] * len(self.devices), 0, power_w, apparent_w, False, "idle", 96.0)
|
| 406 |
+
|
| 407 |
+
self._buffer.append(feat)
|
| 408 |
+
buf_len = len(self._buffer)
|
| 409 |
+
min_buf = max(10, self.window_size // 3)
|
| 410 |
+
|
| 411 |
+
if buf_len < min_buf:
|
| 412 |
+
warmup_confidence = round(18 + min(1.0, buf_len / min_buf) * 32, 1)
|
| 413 |
+
return self._pack(
|
| 414 |
+
"filling_buffer",
|
| 415 |
+
[],
|
| 416 |
+
[0.0] * len(self.devices),
|
| 417 |
+
buf_len,
|
| 418 |
+
power_w,
|
| 419 |
+
apparent_w,
|
| 420 |
+
False,
|
| 421 |
+
"warming",
|
| 422 |
+
warmup_confidence,
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
buffer_array = np.array(self._buffer, dtype=np.float32)
|
| 426 |
+
if buf_len < self.window_size:
|
| 427 |
+
repeat = int(np.ceil(self.window_size / buf_len))
|
| 428 |
+
window = np.tile(buffer_array, (repeat, 1))[: self.window_size]
|
| 429 |
+
else:
|
| 430 |
+
window = buffer_array[-self.window_size :]
|
| 431 |
+
|
| 432 |
+
scaled = self.scaler.transform(window)
|
| 433 |
+
probs = self.model.predict(np.expand_dims(scaled, axis=0), verbose=0)[0]
|
| 434 |
+
|
| 435 |
+
model_active = self._model_active_from_probs(probs, power_w)
|
| 436 |
+
refined_active = self._refine_active_devices(model_active, probs, power_w)
|
| 437 |
+
refined_label = self._canonical_label(refined_active)
|
| 438 |
+
final_label = self._vote_label(refined_label)
|
| 439 |
+
final_active = self._active_from_label(final_label)
|
| 440 |
+
status = "ready" if buf_len >= self.window_size else "loading"
|
| 441 |
+
|
| 442 |
+
confidence = self._compute_detection_confidence(
|
| 443 |
+
final_active,
|
| 444 |
+
probs,
|
| 445 |
+
status,
|
| 446 |
+
buf_len,
|
| 447 |
+
final_label,
|
| 448 |
+
power_w,
|
| 449 |
+
model_active,
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
return self._pack(
|
| 453 |
+
final_label,
|
| 454 |
+
final_active,
|
| 455 |
+
[float(prob) for prob in probs],
|
| 456 |
+
buf_len,
|
| 457 |
+
power_w,
|
| 458 |
+
apparent_w,
|
| 459 |
+
buf_len >= self.window_size,
|
| 460 |
+
status,
|
| 461 |
+
confidence,
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
def _pack(
|
| 465 |
+
self,
|
| 466 |
+
label: str,
|
| 467 |
+
active_devices: list[str],
|
| 468 |
+
probs: list[float],
|
| 469 |
+
buffer_fill: int,
|
| 470 |
+
power_w: float,
|
| 471 |
+
apparent_w: float,
|
| 472 |
+
is_ready: bool,
|
| 473 |
+
buffer_status: str,
|
| 474 |
+
confidence: float = 0.0,
|
| 475 |
+
) -> dict:
|
| 476 |
+
return {
|
| 477 |
+
"label": label,
|
| 478 |
+
"active_devices": active_devices,
|
| 479 |
+
"probs": list(zip(self.devices, probs)),
|
| 480 |
+
"buffer_fill": buffer_fill,
|
| 481 |
+
"power_w": round(power_w, 2),
|
| 482 |
+
"apparent_va": round(apparent_w, 2),
|
| 483 |
+
"is_ready": is_ready,
|
| 484 |
+
"buffer_status": buffer_status,
|
| 485 |
+
"confidence": confidence,
|
| 486 |
+
"model_version": self.meta.get("model_version", "v9_multilabel"),
|
| 487 |
+
}
|
ml_service/run.ps1
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Jalankan ML service dari folder ml_service (memuat .env dari root proyek)
|
| 2 |
+
$ErrorActionPreference = "Stop"
|
| 3 |
+
$root = Split-Path -Parent $PSScriptRoot
|
| 4 |
+
Set-Location $PSScriptRoot
|
| 5 |
+
|
| 6 |
+
if (-not (Test-Path (Join-Path $root "src\nilm_models_v9\best_nilm_model.keras"))) {
|
| 7 |
+
Write-Host "ERROR: Model tidak ditemukan di src\nilm_models_v9\" -ForegroundColor Red
|
| 8 |
+
Write-Host "Pastikan best_nilm_model.keras dan meta_nilm.json ada." -ForegroundColor Yellow
|
| 9 |
+
exit 1
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
$env:NILM_MODEL_DIR = "src/nilm_models_v9"
|
| 13 |
+
Write-Host "Memulai ML service (port 5001)..." -ForegroundColor Cyan
|
| 14 |
+
python app.py
|
ml_service/thingsboard_client.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fetch latest telemetry from ThingsBoard (server-side, for /dashboard/latest)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
TELEMETRY_KEYS = {
|
| 11 |
+
"voltage": os.environ.get("THINGSBOARD_KEY_VOLTAGE", "tegangan").strip() or "tegangan",
|
| 12 |
+
"current": os.environ.get("THINGSBOARD_KEY_CURRENT", "arus").strip() or "arus",
|
| 13 |
+
"power": os.environ.get("THINGSBOARD_KEY_POWER", "daya").strip() or "daya",
|
| 14 |
+
"energy": os.environ.get("THINGSBOARD_KEY_ENERGY", "kwh").strip() or "kwh",
|
| 15 |
+
"frequency": os.environ.get("THINGSBOARD_KEY_FREQUENCY", "frekuensi").strip() or "frekuensi",
|
| 16 |
+
"power_factor": os.environ.get("THINGSBOARD_KEY_POWER_FACTOR", "power_factor").strip()
|
| 17 |
+
or "power_factor",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _base_url() -> str:
|
| 22 |
+
url = (os.environ.get("THINGSBOARD_BASE_URL") or "").strip().rstrip("/")
|
| 23 |
+
if not url:
|
| 24 |
+
raise RuntimeError("THINGSBOARD_BASE_URL belum diatur di environment ML service.")
|
| 25 |
+
return url
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _device_token() -> str:
|
| 29 |
+
return (os.environ.get("THINGSBOARD_ACCESS_TOKEN") or "").strip()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _api_token() -> str:
|
| 33 |
+
return (
|
| 34 |
+
(os.environ.get("THINGSBOARD_API_TOKEN") or os.environ.get("THINGSBOARD_JWT_TOKEN") or "").strip()
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _device_id() -> str:
|
| 39 |
+
return (os.environ.get("THINGSBOARD_DEVICE_ID") or "").strip()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _login_token() -> str:
|
| 43 |
+
username = (os.environ.get("THINGSBOARD_USERNAME") or "").strip()
|
| 44 |
+
password = (os.environ.get("THINGSBOARD_PASSWORD") or "").strip()
|
| 45 |
+
if not username or not password:
|
| 46 |
+
raise RuntimeError("THINGSBOARD_USERNAME/PASSWORD belum diatur.")
|
| 47 |
+
response = requests.post(
|
| 48 |
+
f"{_base_url()}/api/auth/login",
|
| 49 |
+
json={"username": username, "password": password},
|
| 50 |
+
timeout=15,
|
| 51 |
+
)
|
| 52 |
+
response.raise_for_status()
|
| 53 |
+
payload = response.json()
|
| 54 |
+
token = payload.get("token")
|
| 55 |
+
if not token:
|
| 56 |
+
raise RuntimeError("Login ThingsBoard tidak mengembalikan token.")
|
| 57 |
+
return str(token)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _tenant_auth_header(token: str) -> str:
|
| 61 |
+
"""
|
| 62 |
+
ThingsBoard 4.3+ REST API key: X-Authorization: ApiKey <tb_...>
|
| 63 |
+
JWT dari /api/auth/login: X-Authorization: Bearer <jwt>
|
| 64 |
+
"""
|
| 65 |
+
normalized = token.strip()
|
| 66 |
+
if normalized.startswith("tb_"):
|
| 67 |
+
return f"ApiKey {normalized}"
|
| 68 |
+
if normalized.startswith("eyJ"):
|
| 69 |
+
return f"Bearer {normalized}"
|
| 70 |
+
# Default: treat as REST API key (ThingsBoard Cloud)
|
| 71 |
+
return f"ApiKey {normalized}"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _auth_headers() -> dict[str, str]:
|
| 75 |
+
api_token = _api_token()
|
| 76 |
+
if api_token:
|
| 77 |
+
return {"X-Authorization": _tenant_auth_header(api_token)}
|
| 78 |
+
return {"X-Authorization": f"Bearer {_login_token()}"}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _resolve_device_id(headers: dict[str, str]) -> str:
|
| 82 |
+
device_id = _device_id()
|
| 83 |
+
if device_id:
|
| 84 |
+
return device_id
|
| 85 |
+
token = _device_token()
|
| 86 |
+
if not token:
|
| 87 |
+
raise RuntimeError("THINGSBOARD_DEVICE_ID atau THINGSBOARD_ACCESS_TOKEN wajib diisi.")
|
| 88 |
+
response = requests.get(
|
| 89 |
+
f"{_base_url()}/api/device/info",
|
| 90 |
+
params={"deviceToken": token},
|
| 91 |
+
headers=headers,
|
| 92 |
+
timeout=15,
|
| 93 |
+
)
|
| 94 |
+
response.raise_for_status()
|
| 95 |
+
payload = response.json()
|
| 96 |
+
resolved = (payload.get("id") or {}).get("id")
|
| 97 |
+
if not resolved:
|
| 98 |
+
raise RuntimeError("Device ID ThingsBoard tidak ditemukan dari access token.")
|
| 99 |
+
return str(resolved)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _parse_value(points: list[dict[str, Any]] | None, default: float = 0.0) -> float:
|
| 103 |
+
if not points:
|
| 104 |
+
return default
|
| 105 |
+
raw = points[0].get("value")
|
| 106 |
+
if raw is None or raw == "":
|
| 107 |
+
return default
|
| 108 |
+
try:
|
| 109 |
+
return float(raw)
|
| 110 |
+
except (TypeError, ValueError):
|
| 111 |
+
return default
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _telemetry_via_device_api() -> dict[str, Any]:
|
| 115 |
+
token = _device_token()
|
| 116 |
+
if not token:
|
| 117 |
+
raise RuntimeError("THINGSBOARD_ACCESS_TOKEN kosong untuk mode device API.")
|
| 118 |
+
keys = ",".join(TELEMETRY_KEYS.values())
|
| 119 |
+
response = requests.get(
|
| 120 |
+
f"{_base_url()}/api/v1/{token}/telemetry",
|
| 121 |
+
params={"keys": keys, "limit": 1},
|
| 122 |
+
timeout=15,
|
| 123 |
+
)
|
| 124 |
+
response.raise_for_status()
|
| 125 |
+
return response.json()
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _telemetry_via_tenant_api() -> dict[str, Any]:
|
| 129 |
+
headers = _auth_headers()
|
| 130 |
+
device_id = _resolve_device_id(headers)
|
| 131 |
+
keys = ",".join(TELEMETRY_KEYS.values())
|
| 132 |
+
response = requests.get(
|
| 133 |
+
f"{_base_url()}/api/plugins/telemetry/DEVICE/{device_id}/values/timeseries",
|
| 134 |
+
params={"keys": keys, "limit": 1},
|
| 135 |
+
headers=headers,
|
| 136 |
+
timeout=15,
|
| 137 |
+
)
|
| 138 |
+
response.raise_for_status()
|
| 139 |
+
return response.json()
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _sample_from_telemetry(telemetry: dict[str, Any]) -> dict[str, float]:
|
| 143 |
+
return {
|
| 144 |
+
"voltage": _parse_value(telemetry.get(TELEMETRY_KEYS["voltage"]), 220.0),
|
| 145 |
+
"current": _parse_value(telemetry.get(TELEMETRY_KEYS["current"]), 0.0),
|
| 146 |
+
"power": _parse_value(telemetry.get(TELEMETRY_KEYS["power"]), 0.0),
|
| 147 |
+
"energy": _parse_value(telemetry.get(TELEMETRY_KEYS["energy"]), 0.0),
|
| 148 |
+
"frequency": _parse_value(telemetry.get(TELEMETRY_KEYS["frequency"]), 50.0),
|
| 149 |
+
"power_factor": _parse_value(telemetry.get(TELEMETRY_KEYS["power_factor"]), 0.0),
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def fetch_thingsboard_sample() -> dict[str, float]:
|
| 154 |
+
auth_mode = (os.environ.get("THINGSBOARD_AUTH_MODE") or "auto").strip().lower()
|
| 155 |
+
|
| 156 |
+
if auth_mode == "device_token":
|
| 157 |
+
return _sample_from_telemetry(_telemetry_via_device_api())
|
| 158 |
+
|
| 159 |
+
if auth_mode in ("api_token", "jwt", "login"):
|
| 160 |
+
return _sample_from_telemetry(_telemetry_via_tenant_api())
|
| 161 |
+
|
| 162 |
+
# auto: tenant API jika ada API token / login; fallback device token jika 401
|
| 163 |
+
if _api_token() or (_device_id() and not _device_token()):
|
| 164 |
+
try:
|
| 165 |
+
return _sample_from_telemetry(_telemetry_via_tenant_api())
|
| 166 |
+
except requests.HTTPError as exc:
|
| 167 |
+
status = exc.response.status_code if exc.response is not None else None
|
| 168 |
+
if status in (401, 403) and _device_token():
|
| 169 |
+
return _sample_from_telemetry(_telemetry_via_device_api())
|
| 170 |
+
raise
|
| 171 |
+
|
| 172 |
+
if _device_token():
|
| 173 |
+
return _sample_from_telemetry(_telemetry_via_device_api())
|
| 174 |
+
|
| 175 |
+
return _sample_from_telemetry(_telemetry_via_tenant_api())
|