lyimo commited on
Commit
f456aff
·
verified ·
1 Parent(s): 04ce2b8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +226 -196
app.py CHANGED
@@ -1,8 +1,12 @@
1
  """
2
- Appliance Health & Leakage Monitor — Gradio dashboard (Hugging Face Spaces ready).
 
 
 
 
3
 
4
  Run locally: python app.py
5
- Data: expects data/ampds2_hourly.parquet (create with prepare_data.py)
6
  """
7
  import os
8
  import matplotlib
@@ -16,7 +20,6 @@ import engine
16
 
17
  DATA_PATH = os.environ.get("AMPDS2_PARQUET", "data/ampds2_hourly.parquet")
18
 
19
- # ---- load data once at startup so dropdowns populate immediately ----
20
  DF = MAINS = None
21
  SUBS = []
22
  LOAD_ERR = ""
@@ -25,114 +28,130 @@ try:
25
  except Exception as e:
26
  LOAD_ERR = str(e)
27
 
28
- CHOICES = [(f"{engine.AMPDS2_NAMES.get(c, c)} ({c})", c) for c in SUBS]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  CSS = """
31
- .kpis{display:flex;gap:10px;flex-wrap:wrap;margin:6px 0}
32
- .card{flex:1;min-width:120px;background:#0f172a;color:#e2e8f0;border-radius:12px;padding:10px 14px;border:1px solid #1e293b}
33
- .card .l{font-size:11px;letter-spacing:.05em;color:#94a3b8;text-transform:uppercase}
34
- .card .v{font-size:20px;font-weight:700;margin-top:2px}
35
- .card.bad{background:#3f1d1d;border-color:#7f1d1d}
36
- .card.good{background:#0f2a1a;border-color:#14532d}
37
- .alerts{display:flex;flex-direction:column;gap:6px;margin-bottom:10px}
38
- .alert{padding:8px 12px;border-radius:8px;border-left:4px solid #64748b;background:#1e293b;color:#e2e8f0;font-size:13px}
39
- .alert.crit{border-left-color:#ef4444;background:#3f1d1d}
40
- .alert.warn{border-left-color:#f59e0b;background:#3a2c12}
41
- .alert.ok{border-left-color:#22c55e;background:#0f2a1a}
42
- .grid{width:100%;border-collapse:collapse;font-size:13px}
43
- .grid th,.grid td{padding:6px 10px;border-bottom:1px solid #1e293b;text-align:left;color:#e2e8f0}
44
- .grid th{color:#94a3b8;text-transform:uppercase;font-size:11px;letter-spacing:.04em}
45
- .grid td.num{text-align:right;font-variant-numeric:tabular-nums}
46
- .badge{padding:2px 8px;border-radius:999px;font-size:11px;font-weight:700}
47
- .badge.ok{background:#14532d;color:#bbf7d0}
48
- .badge.over{background:#7f1d1d;color:#fecaca}
49
- .badge.off{background:#7f1d1d;color:#fecaca}
50
- .badge.under{background:#78350f;color:#fde68a}
51
- .badge.idle{background:#334155;color:#cbd5e1}
52
- .panel-wrap{background:#0b1220;border-radius:12px;padding:12px;border:1px solid #1e293b}
 
 
53
  """
54
 
55
- ICON = {"crit": "⚠", "warn": "▲"}
56
-
57
 
58
  # ----------------------------------------------------------------- HTML builders
59
- def kpi_html(k, leak, n_alerts):
60
- leak_cls = "bad" if leak["flag"] else "good"
61
- al_cls = "bad" if n_alerts else "good"
62
- return f"""
63
- <div class="kpis">
64
- <div class="card"><div class="l">Sim time</div><div class="v">{k['time']:%Y-%m-%d %H:%M}</div></div>
65
- <div class="card"><div class="l">Live load</div><div class="v">{k['load_W']:.0f} W</div></div>
66
- <div class="card"><div class="l">Energy (playback)</div><div class="v">{k['energy_kWh']:.1f} kWh</div></div>
67
- <div class="card {leak_cls}"><div class="l">Residual / leak</div><div class="v">{leak['now']:.0f} W</div></div>
68
- <div class="card {al_cls}"><div class="l">Active alerts</div><div class="v">{n_alerts}</div></div>
69
- <div class="card"><div class="l">Progress</div><div class="v">{k['i']+1}/{k['n']}</div></div>
70
- </div>"""
71
-
72
-
73
- def panel_html(alerts, rows, leak):
74
- if alerts:
75
- items = "".join(
76
- f'<div class="alert {a["severity"]}"><b>{ICON.get(a["severity"], "•")} {a["name"]}</b> '
77
- f'— {a["status"]}: {a["msg"]}</div>'
78
- for a in alerts)
79
- else:
80
- items = '<div class="alert ok">✓ All appliances within normal range · no leakage detected</div>'
81
- cells = ""
 
 
 
 
 
 
 
 
 
 
 
82
  for r in sorted(rows, key=lambda r: -r["live_W"]):
83
- st = r["status"]
84
- cells += (f'<tr><td>{r["name"]}</td><td class="num">{r["live_W"]:,}</td>'
85
- f'<td class="num">{r["rated_W"]:,}</td>'
86
- f'<td><span class="badge {st.lower()}">{st}</span></td></tr>')
87
- grid = (f'<table class="grid"><thead><tr><th>Appliance</th><th>Live W</th>'
88
- f'<th>Rated W</th><th>Status</th></tr></thead><tbody>{cells}</tbody></table>')
89
- return f'<div class="panel-wrap"><div class="alerts">{items}</div>{grid}</div>'
 
90
 
91
 
92
- # --------------------------------------------------------------------- charts
93
- def chart_focus(sim, cursor):
94
  code = sim.focus
95
- name = engine.AMPDS2_NAMES.get(code, code)
96
  t = sim.play_index
97
- L = sim.live()
98
- y = L["sub"][code].to_numpy()
99
- fig = Figure(figsize=(7, 3.1), dpi=100)
100
  ax = fig.add_subplot(111)
101
- env = sim.envelopes.get(code)
102
- if env is not None:
103
- pt, q10, q90 = env
104
- ax.fill_between(t, q10, q90, color="#94a3b8", alpha=0.22, label="expected band (TimesFM q10–q90)")
105
- ax.plot(t, pt, color="#64748b", lw=0.8, ls="--", label="expected")
106
- ax.plot(t[:cursor + 1], y[:cursor + 1], color="#2563eb", lw=1.3, label="actual (live)")
107
  if sim.rated.get(code, 0) > 0:
108
- ax.axhline(sim.rated[code], color="#16a34a", lw=0.8, ls=":", label=f"rated {sim.rated[code]:.0f} W")
109
- ax.axvline(t[cursor], color="#111827", lw=0.8, alpha=0.6)
110
- ax.set_title(f"{name} ({code}) — live draw vs expected", fontsize=10)
111
- ax.set_ylabel("Power (W)", fontsize=8)
112
- ax.tick_params(labelsize=7)
113
- ax.legend(fontsize=6.5, loc="upper left", framealpha=0.85)
114
- fig.autofmt_xdate(rotation=0, ha="center")
115
- fig.tight_layout()
116
- return fig
117
-
118
-
119
- def chart_house(sim, cursor):
120
- t = sim.play_index
121
- L = sim.live()
122
- res = L["res"]
123
- fig = Figure(figsize=(7, 3.1), dpi=100)
124
- ax = fig.add_subplot(111)
125
- ax.plot(t, sim.res_upper, color="#f59e0b", lw=0.8, label="normal upper band")
126
- ax.plot(t, sim.res_expected, color="#94a3b8", lw=0.8, ls="--", label="expected residual")
127
- ax.plot(t[:cursor + 1], res[:cursor + 1], color="#7c3aed", lw=1.3, label="residual (live)")
128
- if sim.leak is not None:
129
- ts = t[min(sim.leak["start"], len(t) - 1)]
130
- ax.axvline(ts, color="#dc2626", lw=0.9, ls="--", label="leak injected")
131
- ax.axvline(t[cursor], color="#111827", lw=0.8, alpha=0.6)
132
- ax.set_title("House energy balance — residual (potential leakage)", fontsize=10)
133
- ax.set_ylabel("W", fontsize=8)
134
- ax.tick_params(labelsize=7)
135
- ax.legend(fontsize=6.5, loc="upper left", framealpha=0.85)
136
  fig.autofmt_xdate(rotation=0, ha="center")
137
  fig.tight_layout()
138
  return fig
@@ -140,47 +159,48 @@ def chart_house(sim, cursor):
140
 
141
  def render(sim, cursor):
142
  cursor = int(min(max(cursor, 0), sim.play_len - 1))
143
- alerts, rows, leak = sim.detect(cursor)
144
  k = sim.kpis(cursor)
145
- return (kpi_html(k, leak, len(alerts)),
146
- chart_focus(sim, cursor),
147
- chart_house(sim, cursor),
148
- panel_html(alerts, rows, leak))
149
 
150
 
151
  # --------------------------------------------------------------------- handlers
152
- def initialize(window_days, ctx_days):
153
  if LOAD_ERR:
154
- msg = (f'<div class="alert crit"><b> Data not found.</b> {LOAD_ERR}</div>')
155
- return [None, 0, msg, None, None, "", "Not initialized.", gr.update()]
 
156
  sim = engine.Simulator(DF, MAINS, SUBS)
157
- sim.setup(int(window_days), int(ctx_days))
158
  model = engine.get_timesfm()
159
  cyc = [c for c in SUBS if c in engine.CYCLIC_CODES and sim.rated.get(c, 0) > 0]
160
- build = list(dict.fromkeys(cyc + [sim.focus]))
161
- sim.build_envelopes(build, model)
162
- eng = "TimesFM 2.5" if model is not None else "statistical fallback (TimesFM unavailable)"
163
- vis = render(sim, 0)
164
- status = (f"Initialized · {sim.play_len} h playback ({window_days} days) · "
165
- f"expected band: {eng} · focus: {engine.AMPDS2_NAMES.get(sim.focus, sim.focus)}")
166
- return [sim, 0, *vis, status, gr.update(choices=sim.appliance_choices(), value=sim.focus)]
 
 
167
 
168
 
169
  def on_tick(sim, cursor, speed):
170
  if sim is None:
171
  return [cursor, gr.update(), gr.update(), gr.update(), gr.update(), gr.update()]
172
- nc = int(min(cursor + int(speed), sim.play_len - 1))
173
- vis = render(sim, nc)
174
- return [nc, *vis, gr.Timer(active=(nc < sim.play_len - 1))]
175
 
176
 
177
- def on_reset(sim):
178
  if sim is None:
179
  return [0, gr.update(), gr.update(), gr.update(), gr.update()]
180
  return [0, *render(sim, 0)]
181
 
182
 
183
- def on_focus(sim, cursor, code):
184
  if sim is None or code is None:
185
  return [sim, gr.update(), gr.update(), gr.update(), gr.update()]
186
  sim.focus = code
@@ -189,117 +209,127 @@ def on_focus(sim, cursor, code):
189
  return [sim, *render(sim, cursor)]
190
 
191
 
192
- def on_apply_inj(sim, cursor, code, kind, pct, start):
193
- if sim is None:
194
  return [sim, gr.update(), gr.update(), gr.update(), gr.update()]
195
- sim.set_injection(code, kind, pct, start)
 
 
 
 
 
 
 
 
 
196
  return [sim, *render(sim, cursor)]
197
 
198
 
199
- def on_clear_inj(sim, cursor):
200
  if sim is None:
201
  return [sim, gr.update(), gr.update(), gr.update(), gr.update()]
202
  sim.clear_injections()
203
  return [sim, *render(sim, cursor)]
204
 
205
 
206
- def on_apply_leak(sim, cursor, watts, start):
207
- if sim is None:
208
- return [sim, gr.update(), gr.update(), gr.update(), gr.update()]
209
- sim.set_leak(watts, start)
210
- return [sim, *render(sim, cursor)]
211
-
212
-
213
- def on_clear_leak(sim, cursor):
214
  if sim is None:
215
  return [sim, gr.update(), gr.update(), gr.update(), gr.update()]
216
- sim.clear_leak()
 
 
 
217
  return [sim, *render(sim, cursor)]
218
 
219
 
220
  # --------------------------------------------------------------------- layout
221
- with gr.Blocks(css=CSS, theme=gr.themes.Soft(primary_hue="blue"),
222
- title="Appliance Health & Leakage Monitor") as demo:
223
  sim_state = gr.State(None)
224
  cursor_state = gr.State(0)
225
 
226
- gr.Markdown("# Appliance Health & Leakage Monitor\n"
227
- "Plays back the AMPds2 records, builds a TimesFM expected band per appliance, "
228
- "and alerts on over-/under-draw and whole-house leakage. Inject faults below and watch it respond.")
229
- init_status = gr.Markdown("Set a window and click **Initialize** to begin." +
230
- (f" \n⚠ **{LOAD_ERR}**" if LOAD_ERR else ""))
231
 
 
 
 
232
  with gr.Row():
233
- window_days = gr.Slider(7, 120, value=45, step=1, label="Playback window (days)")
234
- ctx_days = gr.Slider(14, 120, value=60, step=1, label="Healthy context (days, for baselines + TimesFM)")
235
- init_btn = gr.Button("Initialize", variant="primary")
236
-
237
- kpi = gr.HTML()
238
-
239
- with gr.Row():
240
- play_btn = gr.Button("▶ Play")
241
- pause_btn = gr.Button("⏸ Pause")
242
- step_btn = gr.Button("⏭ Step")
243
- reset_btn = gr.Button("⟲ Reset")
244
- speed = gr.Slider(1, 24, value=6, step=1, label="Speed (hours / tick)")
245
 
246
  with gr.Row():
 
247
  with gr.Column(scale=1):
248
- with gr.Group():
249
- gr.Markdown("### Inject appliance fault")
250
- inj_code = gr.Dropdown(choices=CHOICES, label="Appliance",
251
- value=(CHOICES[0][1] if CHOICES else None))
252
- inj_kind = gr.Radio(["Over-draw", "Under-draw", "Off"], value="Over-draw", label="Fault type")
253
- inj_pct = gr.Slider(0, 200, value=40, step=5, label="Magnitude (% of rated)")
254
- inj_start = gr.Slider(0, 100, value=0, step=1, label="Start at (% of timeline)")
255
- with gr.Row():
256
- inj_apply = gr.Button("Apply fault", variant="primary")
257
- inj_clear = gr.Button("Clear faults")
258
- with gr.Group():
259
- gr.Markdown("### Inject house leakage")
260
- leak_W = gr.Slider(0, 1500, value=300, step=25, label="Leakage load (W, continuous)")
261
- leak_start = gr.Slider(0, 100, value=20, step=1, label="Start at (% of timeline)")
262
- with gr.Row():
263
- leak_apply = gr.Button("Apply leakage", variant="primary")
264
- leak_clear = gr.Button("Clear leakage")
265
- focus_dd = gr.Dropdown(choices=CHOICES, label="Chart focus appliance",
266
- value=(CHOICES[0][1] if CHOICES else None))
 
 
 
 
 
 
 
267
  with gr.Column(scale=2):
268
- fig_focus = gr.Plot(label="Focus appliance")
269
- fig_house = gr.Plot(label="House residual / leakage")
270
-
271
- gr.Markdown("### Alerts & appliance status")
272
- panel = gr.HTML()
 
 
273
 
274
  timer = gr.Timer(0.8, active=False)
275
 
276
- with gr.Accordion("About / how detection works", open=False):
277
  gr.Markdown(
278
- "- **Expected band** is forecast once by **TimesFM 2.5** from the healthy context window "
279
- "(falls back to a seasonal profile if TimesFM isn't installed).\n"
280
- "- **Over/under/off** fire when an appliance's trailing ON-power deviates from its healthy "
281
- "**rated** value (robust to on/off cycling). The TimesFM band is the visual evidence on the chart.\n"
282
- "- **Leakage** = whole-house meter Σ appliances. Appliance faults raise the mains too, so they "
283
- "do **not** move the residual only a true leak does. That keeps the two alert types independent.\n"
284
- "- No real fault labels exist in AMPds2, so you validate by **injecting** faults and watching response.")
 
 
285
 
286
  # ---- wiring ----
287
- vis_out = [kpi, fig_focus, fig_house, panel]
288
- init_btn.click(initialize, [window_days, ctx_days],
289
- [sim_state, cursor_state, *vis_out, init_status, focus_dd])
290
 
291
  play_btn.click(lambda: gr.Timer(active=True), None, timer)
292
  pause_btn.click(lambda: gr.Timer(active=False), None, timer)
293
- timer.tick(on_tick, [sim_state, cursor_state, speed], [cursor_state, *vis_out, timer])
294
- step_btn.click(on_tick, [sim_state, cursor_state, speed], [cursor_state, *vis_out, timer])
295
- reset_btn.click(on_reset, [sim_state], [cursor_state, *vis_out])
296
-
297
- focus_dd.change(on_focus, [sim_state, cursor_state, focus_dd], [sim_state, *vis_out])
298
- inj_apply.click(on_apply_inj, [sim_state, cursor_state, inj_code, inj_kind, inj_pct, inj_start],
299
- [sim_state, *vis_out])
300
- inj_clear.click(on_clear_inj, [sim_state, cursor_state], [sim_state, *vis_out])
301
- leak_apply.click(on_apply_leak, [sim_state, cursor_state, leak_W, leak_start], [sim_state, *vis_out])
302
- leak_clear.click(on_clear_leak, [sim_state, cursor_state], [sim_state, *vis_out])
 
303
 
304
  if __name__ == "__main__":
305
  demo.launch()
 
1
  """
2
+ Appliance Health & Leakage Monitor — plain "government service" interface.
3
+
4
+ Goal: anyone can use it first time. A clear table of every appliance (estimated vs
5
+ actual watts + status), tick-box selection, plain-language fault labels, and a
6
+ high-contrast status banner. The simulation engine (engine.py) is unchanged.
7
 
8
  Run locally: python app.py
9
+ Data: expects data/ampds2_hourly.parquet
10
  """
11
  import os
12
  import matplotlib
 
20
 
21
  DATA_PATH = os.environ.get("AMPDS2_PARQUET", "data/ampds2_hourly.parquet")
22
 
 
23
  DF = MAINS = None
24
  SUBS = []
25
  LOAD_ERR = ""
 
28
  except Exception as e:
29
  LOAD_ERR = str(e)
30
 
31
+
32
+ def nice(code):
33
+ n = engine.AMPDS2_NAMES.get(code, code)
34
+ return n if n != code else code.replace("_", " ").title()
35
+
36
+
37
+ CHOICES = [(nice(c), c) for c in SUBS]
38
+
39
+ # ---- plain-language maps ----
40
+ PROBLEMS = ["Working fine",
41
+ "Drawing too much (worn)",
42
+ "Drawing too little (weak)",
43
+ "Not running (broken)"]
44
+ KIND = {"Drawing too much (worn)": "Over-draw",
45
+ "Drawing too little (weak)": "Under-draw",
46
+ "Not running (broken)": "Off"}
47
+ OVER_PCT = {"Low": 25, "Medium": 60, "High": 120}
48
+ UNDER_PCT = {"Low": 20, "Medium": 45, "High": 75}
49
+ LEAK_W = {"None": 0, "Small": 150, "Medium": 450, "Large": 900}
50
+ SPEED = {"Slow": 2, "Normal": 6, "Fast": 12}
51
+ STATUS_PLAIN = {"OK": ("Normal", "s-ok"), "OVER": ("Drawing too much", "s-bad"),
52
+ "UNDER": ("Drawing too little", "s-warn"), "OFF": ("Not running", "s-bad"),
53
+ "idle": ("Not in use", "s-idle")}
54
+
55
+ # Force the plain light theme (avoids the dark-mode low-contrast problem entirely).
56
+ FORCE_LIGHT = """() => {
57
+ const u = new URL(window.location.href);
58
+ if (u.searchParams.get('__theme') !== 'light') { u.searchParams.set('__theme','light'); window.location.replace(u.href); }
59
+ }"""
60
 
61
  CSS = """
62
+ .gradio-container{max-width:1040px !important;margin:0 auto !important;
63
+ font-family:Arial,"Helvetica Neue",Helvetica,sans-serif;background:#ffffff !important;}
64
+ .govhead{background:#0b0c0c;color:#fff;padding:16px 20px;border-bottom:6px solid #1d70b8;}
65
+ .govhead h1{margin:0;font-size:24px;font-weight:700;color:#fff;}
66
+ .govhead .sub{color:#cfd2d4;font-size:14px;margin-top:4px;}
67
+ .step{font-size:18px;font-weight:700;color:#0b0c0c;border-bottom:2px solid #0b0c0c;padding-bottom:4px;margin:4px 0 2px;}
68
+ .help{color:#505a5f;font-size:14px;margin:2px 0 8px;}
69
+ .stats{display:flex;border:1px solid #b1b4b6;margin:4px 0;}
70
+ .stat{flex:1;padding:12px 14px;border-right:1px solid #b1b4b6;background:#ffffff;}
71
+ .stat:last-child{border-right:none;}
72
+ .stat .l{font-size:12px;color:#505a5f;text-transform:uppercase;letter-spacing:.03em;}
73
+ .stat .v{font-size:22px;font-weight:700;color:#0b0c0c;margin-top:4px;}
74
+ .banner{padding:14px 16px;border-left:6px solid #00703c;background:#f3f9f4;color:#0b0c0c;font-size:16px;margin:4px 0;}
75
+ .banner.bad{border-left-color:#d4351c;background:#fdf2f1;}
76
+ .banner h3{margin:0 0 6px;font-size:18px;font-weight:700;color:#0b0c0c;}
77
+ .banner ul{margin:6px 0 0;padding-left:20px;} .banner li{margin:3px 0;}
78
+ table.t{width:100%;border-collapse:collapse;font-size:15px;background:#fff;}
79
+ table.t th{text-align:left;border-bottom:2px solid #0b0c0c;padding:8px 10px;font-weight:700;color:#0b0c0c;}
80
+ table.t td{border-bottom:1px solid #b1b4b6;padding:8px 10px;color:#0b0c0c;}
81
+ table.t td.num{text-align:right;font-variant-numeric:tabular-nums;}
82
+ .s-ok{color:#00703c;font-weight:700;} .s-bad{color:#d4351c;font-weight:700;}
83
+ .s-warn{color:#8a6100;font-weight:700;} .s-idle{color:#505a5f;}
84
+ button.primary{background:#00703c !important;color:#fff !important;border:0 !important;border-radius:0 !important;font-weight:700 !important;}
85
+ button.secondary{border-radius:0 !important;}
86
  """
87
 
 
 
88
 
89
  # ----------------------------------------------------------------- HTML builders
90
+ def stats_html(k, leak, n_problems):
91
+ leakv = f"{max(0, leak['now'] - leak['expected']):.0f} W" if leak["flag"] else "None"
92
+ return (f'<div class="stats">'
93
+ f'<div class="stat"><div class="l">Date &amp; time</div><div class="v">{k["time"]:%d %b %Y, %H:%M}</div></div>'
94
+ f'<div class="stat"><div class="l">Whole house now</div><div class="v">{k["load_W"]:,.0f} W</div></div>'
95
+ f'<div class="stat"><div class="l">Possible leak</div><div class="v">{leakv}</div></div>'
96
+ f'<div class="stat"><div class="l">Problems found</div><div class="v">{n_problems}</div></div>'
97
+ f'</div>')
98
+
99
+
100
+ def banner_html(rows, leak):
101
+ issues = []
102
+ if leak["flag"]:
103
+ issues.append(f"Possible electricity leak of about {max(0, leak['now'] - leak['expected']):.0f} W "
104
+ f"somewhere in the house — power being used that no appliance accounts for.")
105
+ for r in rows:
106
+ st, nm = r["status"], nice(r["code"])
107
+ if st == "OVER":
108
+ issues.append(f"{nm} is drawing too much power about {r['live_W']:,} W "
109
+ f"(normally about {r['rated_W']:,} W).")
110
+ elif st == "UNDER":
111
+ issues.append(f"{nm} is drawing less power than normal about {r['live_W']:,} W "
112
+ f"(normally about {r['rated_W']:,} W).")
113
+ elif st == "OFF":
114
+ issues.append(f"{nm} is not running, although it normally would use about {r['rated_W']:,} W.")
115
+ if not issues:
116
+ return ('<div class="banner"><h3>&#10003; System normal</h3>'
117
+ 'All equipment is working as expected and no electricity leak was detected.</div>')
118
+ lis = "".join(f"<li>{x}</li>" for x in issues)
119
+ return f'<div class="banner bad"><h3>&#9888; {len(issues)} problem(s) detected</h3><ul>{lis}</ul></div>'
120
+
121
+
122
+ def table_html(rows):
123
+ body = ""
124
  for r in sorted(rows, key=lambda r: -r["live_W"]):
125
+ label, cls = STATUS_PLAIN.get(r["status"], (r["status"], "s-idle"))
126
+ body += (f'<tr><td>{nice(r["code"])}</td>'
127
+ f'<td class="num">{r["rated_W"]:,}</td>'
128
+ f'<td class="num">{r["live_W"]:,}</td>'
129
+ f'<td class="{cls}">{label}</td></tr>')
130
+ return (f'<table class="t"><thead><tr><th>Equipment</th>'
131
+ f'<th>Estimated watts</th><th>Now using (watts)</th><th>Status</th></tr></thead>'
132
+ f'<tbody>{body}</tbody></table>')
133
 
134
 
135
+ def chart(sim, cursor):
 
136
  code = sim.focus
137
+ name = nice(code)
138
  t = sim.play_index
139
+ y = sim.live()["sub"][code].to_numpy()
140
+ fig = Figure(figsize=(7, 2.9), dpi=100)
141
+ fig.patch.set_facecolor("white")
142
  ax = fig.add_subplot(111)
143
+ ax.set_facecolor("white")
144
+ ax.plot(t[:cursor + 1], y[:cursor + 1], color="#1d70b8", lw=1.5, label="power used now")
 
 
 
 
145
  if sim.rated.get(code, 0) > 0:
146
+ ax.axhline(sim.rated[code], color="#0b0c0c", lw=1.0, ls="--",
147
+ label=f"estimated normal ({sim.rated[code]:.0f} W)")
148
+ ax.axvline(t[cursor], color="#505a5f", lw=0.8)
149
+ ax.set_title(f"{name}: power used over time", fontsize=11, color="#0b0c0c")
150
+ ax.set_ylabel("Watts", fontsize=9)
151
+ ax.tick_params(labelsize=8, colors="#0b0c0c")
152
+ for s in ax.spines.values():
153
+ s.set_color("#b1b4b6")
154
+ ax.legend(fontsize=8, loc="upper left")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  fig.autofmt_xdate(rotation=0, ha="center")
156
  fig.tight_layout()
157
  return fig
 
159
 
160
  def render(sim, cursor):
161
  cursor = int(min(max(cursor, 0), sim.play_len - 1))
162
+ _, rows, leak = sim.detect(cursor)
163
  k = sim.kpis(cursor)
164
+ n = sum(1 for r in rows if r["status"] in ("OVER", "UNDER", "OFF")) + (1 if leak["flag"] else 0)
165
+ return stats_html(k, leak, n), banner_html(rows, leak), table_html(rows), chart(sim, cursor)
 
 
166
 
167
 
168
  # --------------------------------------------------------------------- handlers
169
+ def initialize():
170
  if LOAD_ERR:
171
+ msg = f'<div class="banner bad"><h3>&#9888; System data not found</h3>{LOAD_ERR}</div>'
172
+ return [None, 0, gr.update(), gr.update(), "", msg, "", None,
173
+ "Could not load the system."]
174
  sim = engine.Simulator(DF, MAINS, SUBS)
175
+ sim.setup(45, 60)
176
  model = engine.get_timesfm()
177
  cyc = [c for c in SUBS if c in engine.CYCLIC_CODES and sim.rated.get(c, 0) > 0]
178
+ sim.build_envelopes(list(dict.fromkeys(cyc + [sim.focus])), model)
179
+ s, b, tab, fig = render(sim, 0)
180
+ note = ("System ready. Every appliance is listed below with its estimated normal power and what "
181
+ "it is using now. Set a problem on the left if you like, then press Play.")
182
+ choices = [(nice(c), c) for c in sorted(SUBS, key=lambda c: -sim.rated.get(c, 0))]
183
+ return [sim, 0,
184
+ gr.update(choices=choices, value=[]),
185
+ gr.update(choices=choices, value=sim.focus),
186
+ s, b, tab, fig, note]
187
 
188
 
189
  def on_tick(sim, cursor, speed):
190
  if sim is None:
191
  return [cursor, gr.update(), gr.update(), gr.update(), gr.update(), gr.update()]
192
+ nc = int(min(cursor + SPEED.get(speed, 6), sim.play_len - 1))
193
+ s, b, tab, fig = render(sim, nc)
194
+ return [nc, s, b, tab, fig, gr.Timer(active=(nc < sim.play_len - 1))]
195
 
196
 
197
+ def on_reset_time(sim):
198
  if sim is None:
199
  return [0, gr.update(), gr.update(), gr.update(), gr.update()]
200
  return [0, *render(sim, 0)]
201
 
202
 
203
+ def on_chart(sim, cursor, code):
204
  if sim is None or code is None:
205
  return [sim, gr.update(), gr.update(), gr.update(), gr.update()]
206
  sim.focus = code
 
209
  return [sim, *render(sim, cursor)]
210
 
211
 
212
+ def on_apply_fault(sim, cursor, codes, problem, severity):
213
+ if sim is None or not codes:
214
  return [sim, gr.update(), gr.update(), gr.update(), gr.update()]
215
+ for code in codes:
216
+ if problem == "Working fine":
217
+ sim.injections.pop(code, None)
218
+ sim._dirty = True
219
+ elif problem == "Not running (broken)":
220
+ sim.set_injection(code, "Off", 0, 0)
221
+ elif problem == "Drawing too much (worn)":
222
+ sim.set_injection(code, "Over-draw", OVER_PCT.get(severity, 60), 0)
223
+ elif problem == "Drawing too little (weak)":
224
+ sim.set_injection(code, "Under-draw", UNDER_PCT.get(severity, 45), 0)
225
  return [sim, *render(sim, cursor)]
226
 
227
 
228
+ def on_reset_faults(sim, cursor):
229
  if sim is None:
230
  return [sim, gr.update(), gr.update(), gr.update(), gr.update()]
231
  sim.clear_injections()
232
  return [sim, *render(sim, cursor)]
233
 
234
 
235
+ def on_set_leak(sim, cursor, size):
 
 
 
 
 
 
 
236
  if sim is None:
237
  return [sim, gr.update(), gr.update(), gr.update(), gr.update()]
238
+ if size == "None":
239
+ sim.clear_leak()
240
+ else:
241
+ sim.set_leak(LEAK_W.get(size, 0), 0)
242
  return [sim, *render(sim, cursor)]
243
 
244
 
245
  # --------------------------------------------------------------------- layout
246
+ with gr.Blocks(css=CSS, theme=gr.themes.Default(primary_hue="green", neutral_hue="slate"),
247
+ title="Appliance Power Monitor") as demo:
248
  sim_state = gr.State(None)
249
  cursor_state = gr.State(0)
250
 
251
+ gr.HTML('<div class="govhead"><h1>Appliance Power &amp; Leak Monitor</h1>'
252
+ '<div class="sub">Checks each appliance against its normal power use, and watches the '
253
+ 'whole house for unaccounted electricity (leaks).</div></div>')
 
 
254
 
255
+ gr.HTML('<div class="step">Step 1 — Load the system</div>'
256
+ '<div class="help">This reads the household data and works out the normal power for each appliance. '
257
+ 'It can take up to a minute the first time.</div>')
258
  with gr.Row():
259
+ load_btn = gr.Button("Load the system", variant="primary", scale=0)
260
+ init_status = gr.Markdown("Press **Load the system** to begin."
261
+ + (f" \n**{LOAD_ERR}**" if LOAD_ERR else ""))
 
 
 
 
 
 
 
 
 
262
 
263
  with gr.Row():
264
+ # -------- left: simple controls --------
265
  with gr.Column(scale=1):
266
+ gr.HTML('<div class="step">Step 2 — Simulate a problem (optional)</div>'
267
+ '<div class="help">Tick the equipment, choose what is wrong and how bad, then Apply.</div>')
268
+ equip = gr.CheckboxGroup(choices=CHOICES, label="Equipment to affect")
269
+ problem = gr.Radio(PROBLEMS, value="Working fine", label="What is wrong with it?")
270
+ severity = gr.Radio(["Low", "Medium", "High"], value="Medium",
271
+ label="How bad? (for over/under power)")
272
+ with gr.Row():
273
+ apply_btn = gr.Button("Apply to ticked equipment", variant="primary")
274
+ reset_faults_btn = gr.Button("Set all back to normal", variant="secondary")
275
+
276
+ gr.HTML('<div class="step" style="margin-top:14px">Electricity leak in the house</div>'
277
+ '<div class="help">Simulate power being lost somewhere with no appliance (faulty wiring, '
278
+ 'an unmetered load, theft).</div>')
279
+ leak = gr.Radio(list(LEAK_W.keys()), value="None", label="Leak size")
280
+
281
+ gr.HTML('<div class="step" style="margin-top:14px">Run</div>'
282
+ '<div class="help">Play steps through the records so you can watch the table and status change.</div>')
283
+ with gr.Row():
284
+ play_btn = gr.Button("Play", variant="primary")
285
+ pause_btn = gr.Button("Pause", variant="secondary")
286
+ with gr.Row():
287
+ step_btn = gr.Button("Step", variant="secondary")
288
+ reset_time_btn = gr.Button("Reset time", variant="secondary")
289
+ speed = gr.Radio(list(SPEED.keys()), value="Normal", label="Speed")
290
+
291
+ # -------- right: what you watch --------
292
  with gr.Column(scale=2):
293
+ stats_box = gr.HTML()
294
+ banner_box = gr.HTML()
295
+ gr.HTML('<div class="step">Equipment status</div>')
296
+ table_box = gr.HTML()
297
+ chart_dd = gr.Dropdown(choices=CHOICES, label="Show power chart for",
298
+ value=(CHOICES[0][1] if CHOICES else None))
299
+ chart_box = gr.Plot(show_label=False)
300
 
301
  timer = gr.Timer(0.8, active=False)
302
 
303
+ with gr.Accordion("How this works (plain English)", open=False):
304
  gr.Markdown(
305
+ "**Estimated watts** is the appliance's normal power, learned from a healthy period of data.\n\n"
306
+ "**Now using** is the live power as the records play.\n\n"
307
+ "An appliance is flagged when its power stays clearly above its normal value (worn / drawing too much), "
308
+ "below it (weak), or at zero when it should be running (broken).\n\n"
309
+ "A **leak** is found by comparing the whole-house meter to the sum of all appliances. A faulty "
310
+ "appliance raises the meter by the same amount, so it does not look like a leak only truly "
311
+ "unaccounted power does.\n\n"
312
+ "There are no real fault labels in the data, so you test the system by simulating problems above "
313
+ "and watching it respond.")
314
 
315
  # ---- wiring ----
316
+ vis = [stats_box, banner_box, table_box, chart_box]
317
+ load_btn.click(initialize, None,
318
+ [sim_state, cursor_state, equip, chart_dd, *vis, init_status])
319
 
320
  play_btn.click(lambda: gr.Timer(active=True), None, timer)
321
  pause_btn.click(lambda: gr.Timer(active=False), None, timer)
322
+ timer.tick(on_tick, [sim_state, cursor_state, speed], [cursor_state, *vis, timer])
323
+ step_btn.click(on_tick, [sim_state, cursor_state, speed], [cursor_state, *vis, timer])
324
+ reset_time_btn.click(on_reset_time, [sim_state], [cursor_state, *vis])
325
+
326
+ apply_btn.click(on_apply_fault, [sim_state, cursor_state, equip, problem, severity],
327
+ [sim_state, *vis])
328
+ reset_faults_btn.click(on_reset_faults, [sim_state, cursor_state], [sim_state, *vis])
329
+ leak.change(on_set_leak, [sim_state, cursor_state, leak], [sim_state, *vis])
330
+ chart_dd.change(on_chart, [sim_state, cursor_state, chart_dd], [sim_state, *vis])
331
+
332
+ demo.load(None, None, None, js=FORCE_LIGHT)
333
 
334
  if __name__ == "__main__":
335
  demo.launch()