dhikarachman Cursor commited on
Commit
4429cbc
·
1 Parent(s): 60b35f9

Add Flask ML service (app, predictor, thingsboard client)

Browse files
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())