Pybunny commited on
Commit
84f6082
·
verified ·
1 Parent(s): 542fd0c

Initial Space: Gradio demo, weights pulled from Pybunny/nilmbench-faustine

Browse files
README.md CHANGED
@@ -1,13 +1,42 @@
1
  ---
2
  title: NILMbench
3
- emoji: 🏃
4
- colorFrom: green
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: NILMbench
3
+ emoji:
4
+ colorFrom: indigo
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: 4.44.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ short_description: High-frequency NILM disaggregation on UK-DALE.
12
  ---
13
 
14
+ # NILMbench demo
15
+
16
+ This Space runs the FaustineCNN baseline trained on UK-DALE House 1 against a
17
+ single 6-second 16 kHz voltage/current frame from House 2.
18
+
19
+ * Upload a ``(2, 96000)`` float32 NumPy file, or pick one of the built-in
20
+ example frames.
21
+ * The model returns a per-category predicted power vector, post-processed with
22
+ the recall-constrained validation cutoffs from the paper.
23
+
24
+ The demo intentionally exposes a single frame at a time so the result fits in
25
+ one screen. For full benchmark scoring use the ``nilmbench`` CLI on the
26
+ companion GitHub repo.
27
+
28
+ ## Files
29
+
30
+ | File | Purpose |
31
+ | ----------------- | -------------------------------------------------------- |
32
+ | `app.py` | Gradio entry point |
33
+ | `requirements.txt`| Pinned runtime dependencies |
34
+ | `examples/` | Built-in V/I frames and their ground-truth labels |
35
+ | `model/` | FaustineCNN checkpoint + class names + cutoffs |
36
+
37
+ ## Local development
38
+
39
+ ```bash
40
+ pip install -r requirements.txt
41
+ python app.py
42
+ ```
__pycache__/app.cpython-312.pyc ADDED
Binary file (14.1 kB). View file
 
app.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NILMbench HuggingFace Space.
2
+
3
+ Single-frame demo of the FaustineCNN baseline. Model weights, classes, and
4
+ recall-constrained cutoffs are pulled from the HF model repo
5
+ ``Pybunny/nilmbench-faustine`` at startup. Example frames are bundled with
6
+ the Space so the demo works offline of the laptop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import torch
16
+ import torch.nn as nn
17
+ import torch.nn.functional as F
18
+ import gradio as gr
19
+ import matplotlib
20
+ matplotlib.use("Agg")
21
+ import matplotlib.pyplot as plt
22
+ from huggingface_hub import hf_hub_download
23
+
24
+ HERE = Path(__file__).resolve().parent
25
+ EXAMPLES_DIR = HERE / "examples"
26
+ MODEL_REPO = "Pybunny/nilmbench-faustine"
27
+
28
+ # UK-DALE House 2 calibration constants (from calibration_house_2.cfg).
29
+ V_PER_ADC = 1.88296904357e-7
30
+ I_PER_ADC = 4.77518864497e-8
31
+ ADC_FULL_SCALE = 2 ** 31
32
+ V_FACTOR = ADC_FULL_SCALE * V_PER_ADC # ~404.4
33
+ I_FACTOR = ADC_FULL_SCALE * I_PER_ADC # ~102.5
34
+
35
+
36
+ # ----------------------------------------------------------------------
37
+ # Model (self-contained so the Space has no dependency on the nilmbench pkg)
38
+ # ----------------------------------------------------------------------
39
+ class FaustineCNN(nn.Module):
40
+ def __init__(self, n_categories: int):
41
+ super().__init__()
42
+ self.conv_layers = nn.Sequential(
43
+ nn.Conv2d(2, 16, kernel_size=5, stride=2, padding=2),
44
+ nn.BatchNorm2d(16), nn.ReLU(inplace=True),
45
+ nn.Conv2d(16, 32, kernel_size=5, stride=2, padding=2),
46
+ nn.BatchNorm2d(32), nn.ReLU(inplace=True),
47
+ nn.Conv2d(32, 64, kernel_size=3, stride=2, padding=1),
48
+ nn.BatchNorm2d(64), nn.ReLU(inplace=True),
49
+ nn.Conv2d(64, 128, kernel_size=3, stride=2, padding=1),
50
+ nn.BatchNorm2d(128), nn.ReLU(inplace=True),
51
+ nn.AdaptiveAvgPool2d((1, 1)),
52
+ )
53
+ self.fc_layers = nn.Sequential(
54
+ nn.Linear(128, 1024),
55
+ nn.LayerNorm(1024),
56
+ nn.ReLU(inplace=True),
57
+ nn.Dropout(0.25),
58
+ nn.Linear(1024, 2 * n_categories),
59
+ )
60
+ self.n_categories = n_categories
61
+
62
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
63
+ h = self.conv_layers(x).flatten(1)
64
+ h = self.fc_layers(h).view(x.size(0), self.n_categories, 2)
65
+ return F.softmax(h, dim=-1)[..., 0]
66
+
67
+
68
+ # ----------------------------------------------------------------------
69
+ # Asset loading (Hub)
70
+ # ----------------------------------------------------------------------
71
+ def load_assets():
72
+ classes_path = hf_hub_download(MODEL_REPO, "classes.json")
73
+ cutoffs_path = hf_hub_download(MODEL_REPO, "cutoffs.json")
74
+ weights_path = hf_hub_download(MODEL_REPO, "faustine_best.pt")
75
+
76
+ classes = json.loads(Path(classes_path).read_text())
77
+ cutoffs = json.loads(Path(cutoffs_path).read_text())["cutoffs_W"]
78
+
79
+ model = FaustineCNN(n_categories=len(classes))
80
+ state = torch.load(weights_path, map_location="cpu", weights_only=False)
81
+ if isinstance(state, dict) and "state_dict" in state:
82
+ state = state["state_dict"]
83
+ model.load_state_dict(state)
84
+ model.eval()
85
+ return model, classes, cutoffs
86
+
87
+
88
+ MODEL, CLASSES, CUTOFFS = load_assets()
89
+
90
+
91
+ # ----------------------------------------------------------------------
92
+ # Inference + plotting
93
+ # ----------------------------------------------------------------------
94
+ def _to_2d_image(vi_norm: np.ndarray) -> torch.Tensor:
95
+ if vi_norm.shape != (2, 96000):
96
+ raise ValueError(f"Expected (2, 96000), got {vi_norm.shape}")
97
+ img = vi_norm.reshape(2, 240, 400).astype(np.float32)
98
+ return torch.as_tensor(img).unsqueeze(0)
99
+
100
+
101
+ def predict(vi_norm: np.ndarray, aggregate_W: float) -> dict[str, float]:
102
+ with torch.no_grad():
103
+ scores = MODEL(_to_2d_image(vi_norm)).cpu().numpy().squeeze(0)
104
+ # FaustineCNN outputs per-category Bernoulli activations; renormalise
105
+ # across categories to obtain shares, then scale by the aggregate.
106
+ shares = scores / (scores.sum() + 1e-9)
107
+ raw = shares * float(aggregate_W)
108
+ out = {}
109
+ for k, cls in enumerate(CLASSES):
110
+ cut = CUTOFFS.get(cls, 0.0)
111
+ out[cls] = float(raw[k]) if raw[k] > cut else 0.0
112
+ return out
113
+
114
+
115
+ def make_overview_plot(vi_norm: np.ndarray, preds: dict[str, float],
116
+ truth: dict[str, float] | None) -> plt.Figure:
117
+ v = vi_norm[0].astype(np.float32) * V_FACTOR
118
+ i = vi_norm[1].astype(np.float32) * I_FACTOR
119
+ t = np.arange(len(v)) / 16000
120
+
121
+ fig = plt.figure(figsize=(8.0, 6.0))
122
+ gs = fig.add_gridspec(3, 1, height_ratios=[1.2, 1.2, 1.6], hspace=0.55)
123
+
124
+ ax_v = fig.add_subplot(gs[0])
125
+ ax_v.plot(t, v, color="#1a4f8a", lw=0.4)
126
+ ax_v.set_ylabel("Voltage (V)")
127
+ ax_v.set_xlim(0, 6); ax_v.grid(True, linestyle=":", alpha=0.4)
128
+
129
+ ax_i = fig.add_subplot(gs[1])
130
+ ax_i.plot(t, i, color="#7a1a1a", lw=0.4)
131
+ ax_i.set_ylabel("Current (A)"); ax_i.set_xlabel("Time (s)")
132
+ ax_i.set_xlim(0, 6); ax_i.grid(True, linestyle=":", alpha=0.4)
133
+
134
+ ax_p = fig.add_subplot(gs[2])
135
+ active = [(c, w) for c, w in preds.items() if w > 0]
136
+ active.sort(key=lambda kv: -kv[1])
137
+ if not active:
138
+ active = [("(all categories below cutoff)", 0.0)]
139
+ names = [c for c, _ in active]
140
+ vals = [w for _, w in active]
141
+ y_pos = np.arange(len(names))
142
+ ax_p.barh(y_pos, vals, color="#a63d40", edgecolor="#222", linewidth=0.4,
143
+ label="prediction")
144
+ if truth is not None:
145
+ tvals = [truth.get(c, 0.0) for c in names]
146
+ ax_p.barh(y_pos + 0.32, tvals, height=0.32,
147
+ color="#1a4f8a", alpha=0.6, edgecolor="#222", linewidth=0.4,
148
+ label="ground truth")
149
+ ax_p.set_yticks(y_pos); ax_p.set_yticklabels(names)
150
+ ax_p.invert_yaxis()
151
+ ax_p.set_xlabel("Predicted power (W)")
152
+ ax_p.grid(True, axis="x", linestyle=":", alpha=0.4)
153
+ if truth is not None:
154
+ ax_p.legend(loc="lower right", frameon=False, fontsize=9)
155
+ return fig
156
+
157
+
158
+ # ----------------------------------------------------------------------
159
+ # Gradio handlers
160
+ # ----------------------------------------------------------------------
161
+ def list_examples() -> list[str]:
162
+ if not EXAMPLES_DIR.exists():
163
+ return []
164
+ return sorted(p.stem for p in EXAMPLES_DIR.glob("*.npy"))
165
+
166
+
167
+ def load_example(name: str):
168
+ npy = EXAMPLES_DIR / f"{name}.npy"
169
+ meta = EXAMPLES_DIR / f"{name}.json"
170
+ vi = np.load(npy)
171
+ truth = None
172
+ aggregate = 0.0
173
+ if meta.exists():
174
+ m = json.loads(meta.read_text())
175
+ truth = m.get("truth")
176
+ aggregate = float(m.get("aggregate_W", 0.0))
177
+ if aggregate == 0.0 and truth is not None:
178
+ aggregate = sum(truth.values())
179
+ return vi, truth, aggregate
180
+
181
+
182
+ def run_example(name: str):
183
+ if not name:
184
+ return None, {}
185
+ vi, truth, agg = load_example(name)
186
+ preds = predict(vi, agg)
187
+ return make_overview_plot(vi, preds, truth), preds
188
+
189
+
190
+ def run_upload(file_obj, aggregate_W: float):
191
+ if file_obj is None:
192
+ return None, {}
193
+ vi = np.load(file_obj.name)
194
+ preds = predict(vi, aggregate_W)
195
+ return make_overview_plot(vi, preds, None), preds
196
+
197
+
198
+ # ----------------------------------------------------------------------
199
+ # UI
200
+ # ----------------------------------------------------------------------
201
+ def build_ui() -> gr.Blocks:
202
+ examples = list_examples()
203
+ with gr.Blocks(title="NILMbench demo") as demo:
204
+ gr.Markdown(
205
+ "# NILMbench demo\n"
206
+ "FaustineCNN trained on UK-DALE House 1, applied to a single "
207
+ "6-second 16 kHz V/I segment from House 2. Predicted power is "
208
+ "post-processed with the recall-constrained cutoffs from the paper.\n\n"
209
+ "Source code: <https://github.com/Saharmgh/NILMbench> · "
210
+ "Model: <https://huggingface.co/Pybunny/nilmbench-faustine>"
211
+ )
212
+ with gr.Tabs():
213
+ with gr.TabItem("Built-in example"):
214
+ ex = gr.Dropdown(examples, label="Example frame",
215
+ value=examples[0] if examples else None)
216
+ btn = gr.Button("Run", variant="primary")
217
+ plot_a = gr.Plot()
218
+ lab_a = gr.JSON(label="Predicted power per category (W)")
219
+ btn.click(run_example, ex, [plot_a, lab_a])
220
+ with gr.TabItem("Upload your own"):
221
+ up = gr.File(label="V/I segment (.npy, shape (2, 96000), "
222
+ "FLAC-normalised float in [-1, 1])")
223
+ agg = gr.Slider(0, 8000, value=300, step=10,
224
+ label="Aggregate active power (W)")
225
+ btn2 = gr.Button("Run", variant="primary")
226
+ plot_b = gr.Plot()
227
+ lab_b = gr.JSON(label="Predicted power per category (W)")
228
+ btn2.click(run_upload, [up, agg], [plot_b, lab_b])
229
+ return demo
230
+
231
+
232
+ if __name__ == "__main__":
233
+ build_ui().launch()
examples/baseline_evening.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "truth": {
3
+ "always on": 29.0,
4
+ "electronics & lighting": 100.5948486328125
5
+ },
6
+ "aggregate_W": 129.5948486328125,
7
+ "note": "Quiet baseline: always-on + electronics & lighting only.",
8
+ "source": "UK-DALE House 2, dense benchmark set, frame 0"
9
+ }
examples/baseline_evening.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bf2e6dd847d124f800e847978adcba984db62e7cc431c65ec58a0dbc53cfeccc
3
+ size 384128
examples/cooking_event.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "truth": {
3
+ "always on": 15.0,
4
+ "cooking": 4120.15185546875,
5
+ "electronics & lighting": 13.0,
6
+ "fridge": 10.0
7
+ },
8
+ "aggregate_W": 4158.15185546875,
9
+ "note": "High-power cooking event (~4 kW).",
10
+ "source": "UK-DALE House 2, dense benchmark set, frame 39"
11
+ }
examples/cooking_event.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b76b49649240608122d64e6c40c084fbc4b0b3275fd0d668f0501ebcbe517b4a
3
+ size 384128
examples/dishwasher_event.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "truth": {
3
+ "always on": 15.328383445739746,
4
+ "cooking": 1.0,
5
+ "dishwasher": 3170.2822265625,
6
+ "electronics & lighting": 117.14686584472656,
7
+ "fridge": 83.0,
8
+ "washing machine": 3.0
9
+ },
10
+ "aggregate_W": 3389.757568359375,
11
+ "note": "Dishwasher event (~3 kW).",
12
+ "source": "UK-DALE House 2, dense benchmark set, frame 1940"
13
+ }
examples/dishwasher_event.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7b4df3cbbc987d29ba184e0f805f8a0cfd247b74c85ace18e916fb2bf53215f6
3
+ size 384128
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ torch>=2.1
2
+ numpy>=1.24
3
+ matplotlib>=3.7
4
+ gradio>=4.44