Coverage for src/distopf/plot.py: 6%

460 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-11-13 17:34 -0800

1""" 

2plot.py - Plotly visualization functions for LinDistModel results 

3""" 

4 

5from typing import Optional 

6import numpy as np 

7import pandas as pd 

8import plotly.express as px 

9import plotly.graph_objects as go 

10 

11from distopf.matrix_models.base import LinDistBase 

12from distopf.importer import Case 

13 

14def _choose_t(df, t=None): 

15 df = df.copy() 

16 if t is None: 

17 t = 0 

18 if "t" in df.columns: 

19 if t is None and len(df) > 0: 

20 t = min(df.t) 

21 df = df.loc[df.t == t, :] 

22 df = df.drop("t", axis=1) 

23 return df 

24 

25 

26def plot_voltages(v: pd.DataFrame, t=None) -> go.Figure: 

27 """ 

28 Parameters 

29 ---------- 

30 v : pd.DataFrame, Dataframe containing solved voltages for each bus. 

31 Typically generated by the LinDistModel.get_voltages() method. 

32 

33 Returns 

34 ------- 

35 fig : Plotly figure object 

36 Plotly figure object containing the voltage magnitudes for each bus. 

37 """ 

38 v = v.copy() 

39 if "id" not in v.columns: 

40 v["id"] = v.index 

41 if "name" not in v.columns: 

42 v["name"] = v["id"] 

43 # if t is None and "t" in v.columns: 

44 # t = min(v.t) 

45 # if "t" in v.columns: 

46 # v = v.loc[v.t == t, :] 

47 # v = v.drop("t", axis=1) 

48 v = _choose_t(v, t=t) 

49 v = v.melt( 

50 ignore_index=False, id_vars=["id", "name"], var_name="phase", value_name="v" 

51 ) 

52 v["v"] = v["v"].astype(float) 

53 fig = px.scatter(v, x=v.name, y="v", facet_col="phase", color="phase") 

54 fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1].upper())) 

55 fig.for_each_xaxis(lambda a: a.update(title="Bus Name")) 

56 return fig 

57 

58 

59def compare_voltages(v1: pd.DataFrame, v2: pd.DataFrame, t=None) -> go.Figure: 

60 """ 

61 Visually compare voltages by plotting two different results. 

62 Parameters 

63 ---------- 

64 v1 : pd.DataFrame 

65 v2 : pd.DataFrame 

66 

67 Returns 

68 ------- 

69 fig : Plotly figure object 

70 """ 

71 v1 = v1.copy() 

72 v2 = v2.copy() 

73 if "id" not in v1.columns: 

74 v1["id"] = v1.index 

75 if "name" not in v1.columns: 

76 v1["name"] = v1["id"] 

77 if "id" not in v2.columns: 

78 v2["id"] = v2.index 

79 if "name" not in v2.columns: 

80 v2["name"] = v2["id"] 

81 

82 if t is None and "t" in v1.columns: 

83 t1 = min(v1.t) 

84 if t is None and "t" in v2.columns: 

85 t2 = min(v2.t) 

86 assert t1 == t2 

87 if "t" in v1.columns: 

88 v1 = pd.DataFrame(v1.loc[v1.t == t1, :]) 

89 v1 = v1.drop("t", axis=1) 

90 if "t" in v2.columns: 

91 v2 = pd.DataFrame(v2.loc[v2.t == t2, :]) 

92 v2 = v2.drop("t", axis=1) 

93 v1 = v1.melt( 

94 ignore_index=True, var_name="phase", id_vars=["id", "name"], value_name="v1" 

95 ) 

96 v2 = v2.melt( 

97 ignore_index=True, var_name="phase", id_vars=["id", "name"], value_name="v2" 

98 ) 

99 v = pd.merge(v1, v2, on=["id", "name", "phase"]) 

100 v = v.melt( 

101 ignore_index=True, 

102 var_name="value", 

103 id_vars=["id", "name", "phase"], 

104 value_name="v", 

105 ).sort_values(by=["id", "phase"]) 

106 # v1["v1"] = v1["v1"].astype(float) 

107 # v2["v2"] = v2["v2"].astype(float) 

108 # v = pd.merge(v1, v2, on=["name", "phase"]) 

109 fig = px.line(v, x="name", facet_col="phase", y="v", color="value", markers=True) 

110 fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1].upper())) 

111 fig.for_each_xaxis(lambda a: a.update(title="Bus Name")) 

112 return fig 

113 

114 

115def voltage_differences(v1: pd.DataFrame, v2: pd.DataFrame, t=None) -> go.Figure: 

116 """ 

117 Visually compare voltages from two different results by plotting the difference v1-v2. 

118 Parameters 

119 ---------- 

120 v1 : pd.DataFrame 

121 v2 : pd.DataFrame 

122 

123 Returns 

124 ------- 

125 fig : Plotly figure object 

126 """ 

127 v1 = v1.copy() 

128 v2 = v2.copy() 

129 if "id" not in v1.columns: 

130 v1["id"] = v1.index 

131 if "name" not in v1.columns: 

132 v1["name"] = v1["id"] 

133 if "id" not in v2.columns: 

134 v2["id"] = v2.index 

135 if "name" not in v2.columns: 

136 v2["name"] = v2["id"] 

137 if "t" in v1.columns: 

138 if t is None: 

139 t1 = min(v1.t) 

140 v1 = pd.DataFrame(v1.loc[v1.t == t1, :]) 

141 v1 = v1.drop("t", axis=1) 

142 if "t" in v2.columns: 

143 if t is None: 

144 t2 = min(v2.t) 

145 v2 = pd.DataFrame(v2.loc[v2.t == t2, :]) 

146 v2 = v2.drop("t", axis=1) 

147 v1 = v1.melt( 

148 ignore_index=True, var_name="phase", id_vars=["id", "name"], value_name="v1" 

149 ) 

150 v2 = v2.melt( 

151 ignore_index=True, var_name="phase", id_vars=["id", "name"], value_name="v2" 

152 ) 

153 v = pd.merge(v1, v2, on=["id", "name", "phase"]) 

154 v["diff"] = v["v1"] - v["v2"] 

155 # fig = px.line(v, x="id", y="diff", facet_col="phase") 

156 fig = px.bar( 

157 v, 

158 x="name", 

159 y="diff", 

160 color="phase", 

161 barmode="group", 

162 ) 

163 fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1].upper())) 

164 fig.for_each_xaxis(lambda a: a.update(title="Bus Name")) 

165 return fig 

166 

167 

168def plot_power_flows(s: pd.DataFrame) -> go.Figure: 

169 """ 

170 Plot the active and reactive power flowing into each bus on each phase. 

171 Parameters 

172 ---------- 

173 s : pd.DataFrame 

174 

175 Returns 

176 ------- 

177 fig : Plotly figure object 

178 """ 

179 s = s.copy() 

180 s = s.melt( 

181 ignore_index=True, 

182 id_vars=["fb", "tb", "from_name", "to_name"], 

183 var_name="phase", 

184 value_name="s", 

185 ) 

186 s["p"] = s.s.apply(lambda x: x.real) 

187 s["q"] = s.s.apply(lambda x: x.imag) 

188 del s["s"] 

189 s = s.melt( 

190 ignore_index=True, 

191 id_vars=["fb", "tb", "from_name", "to_name", "phase"], 

192 var_name="part", 

193 value_name="power", 

194 ) 

195 fig = px.bar( 

196 s, 

197 x="to_name", 

198 y="power", 

199 facet_col="phase", 

200 facet_row="part", 

201 color="phase", 

202 labels={"to_name": "To-Bus Name"}, 

203 ) 

204 fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1].upper())) 

205 fig.update_layout( 

206 yaxis4_title="Active Power (p.u.)", 

207 yaxis_title="Reactive Power (p.u.)", 

208 ) 

209 return fig 

210 

211 

212def plot_gens(p_gens: pd.DataFrame, q_gens: pd.DataFrame, t=None) -> go.Figure: 

213 """ 

214 Plot the active and reactive power flowing into each bus on each phase. 

215 Parameters 

216 ---------- 

217 p_gens : pd.DataFrame 

218 q_gens : pd.DataFrame 

219 Returns 

220 ------- 

221 fig : Plotly figure object 

222 """ 

223 p_gens = p_gens.copy() 

224 q_gens = q_gens.copy() 

225 if t is None: 

226 t = 0 

227 if "t" in p_gens.columns: 

228 t1 = t 

229 if t is None and len(p_gens) > 0: 

230 t1 = min(p_gens.t) 

231 p_gens = pd.DataFrame(p_gens.loc[p_gens.t == t1, :]) 

232 p_gens = p_gens.drop("t", axis=1) 

233 if "t" in q_gens.columns: 

234 t2 = t 

235 if t is None and len(q_gens) > 0: 

236 t2 = min(q_gens.t) 

237 q_gens = pd.DataFrame(q_gens.loc[q_gens.t == t2, :]) 

238 q_gens = q_gens.drop("t", axis=1) 

239 

240 p_gens = p_gens.melt( 

241 ignore_index=True, 

242 id_vars=["id", "name"], 

243 var_name="phase", 

244 value_name="P", 

245 ) 

246 q_gens = q_gens.melt( 

247 ignore_index=True, 

248 id_vars=["id", "name"], 

249 var_name="phase", 

250 value_name="Q", 

251 ) 

252 gens = pd.merge(p_gens, q_gens, how="outer", on=["id", "name", "phase"]) 

253 gens.id = p_gens.id 

254 gens.name = p_gens.name 

255 gens.phase = p_gens.phase 

256 gens["P"] = p_gens["P"] 

257 gens["Q"] = q_gens["Q"] 

258 gens = gens.melt( 

259 ignore_index=False, 

260 id_vars=["id", "name", "phase"], 

261 var_name="part", 

262 value_name="power", 

263 ) 

264 fig = px.bar( 

265 gens, 

266 x="name", 

267 y="power", 

268 facet_col="phase", 

269 facet_row="part", 

270 color="phase", 

271 labels={"name": "Bus Name"}, 

272 ) 

273 fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1].upper())) 

274 fig.update_layout( 

275 yaxis4_title="Active Power (p.u.)", 

276 yaxis_title="Reactive Power (p.u.)", 

277 ) 

278 return fig 

279 

280 

281def plot_pq(p: pd.DataFrame, q: pd.DataFrame, t=None) -> go.Figure: 

282 """ 

283 Plot the active and reactive power. 

284 Parameters 

285 ---------- 

286 p : pd.DataFrame 

287 q : pd.DataFrame 

288 Returns 

289 ------- 

290 fig : Plotly figure object 

291 """ 

292 p = p.copy() 

293 q = q.copy() 

294 if t is None: 

295 t = 0 

296 if "t" in p.columns: 

297 t1 = t 

298 if t is None and len(p) > 0: 

299 t1 = min(p.t) 

300 p = pd.DataFrame(p.loc[p.t == t1, :]) 

301 p = p.drop("t", axis=1) 

302 if "t" in q.columns: 

303 t2 = t 

304 if t is None and len(q) > 0: 

305 t2 = min(q.t) 

306 q = pd.DataFrame(q.loc[q.t == t2, :]) 

307 q = q.drop("t", axis=1) 

308 

309 p = p.melt( 

310 ignore_index=True, 

311 id_vars=["id", "name"], 

312 var_name="phase", 

313 value_name="P", 

314 ) 

315 q = q.melt( 

316 ignore_index=True, 

317 id_vars=["id", "name"], 

318 var_name="phase", 

319 value_name="Q", 

320 ) 

321 pq = pd.merge(p, q, how="outer", on=["id", "name", "phase"]) 

322 pq.id = p.id 

323 pq.name = p.name 

324 pq.phase = p.phase 

325 pq["P"] = p["P"] 

326 pq["Q"] = q["Q"] 

327 pq = pq.melt( 

328 ignore_index=False, 

329 id_vars=["id", "name", "phase"], 

330 var_name="part", 

331 value_name="power", 

332 ) 

333 fig = px.bar( 

334 pq, 

335 x="name", 

336 y="power", 

337 facet_col="phase", 

338 facet_row="part", 

339 color="phase", 

340 labels={"name": "Bus Name"}, 

341 ) 

342 fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1].upper())) 

343 fig.update_layout( 

344 yaxis4_title="Active Power (p.u.)", 

345 yaxis_title="Reactive Power (p.u.)", 

346 ) 

347 return fig 

348 

349 

350def compare_flows(s1: pd.DataFrame, s2: pd.DataFrame) -> go.Figure: 

351 """ 

352 Similar to plot_power_flows but plots two results side by side. 

353 Parameters 

354 ---------- 

355 s1 : pd.DataFrame 

356 s2 : pd.DataFrame 

357 

358 Returns 

359 ------- 

360 fig : Plotly figure object 

361 """ 

362 s1 = s1.copy() 

363 s2 = s2.copy() 

364 if "from_name" not in s1.columns: 

365 s1["from_name"] = s1.fb 

366 if "to_name" not in s1.columns: 

367 s1["to_name"] = s1.tb 

368 if "from_name" not in s2.columns: 

369 s2["from_name"] = s2.fb 

370 if "to_name" not in s2.columns: 

371 s2["to_name"] = s2.tb 

372 

373 s1 = s1.melt( 

374 ignore_index=True, 

375 id_vars=["fb", "tb", "from_name", "to_name"], 

376 var_name="phase", 

377 value_name="s", 

378 ) 

379 s1["p"] = s1.s.apply(np.real) 

380 s1["q"] = s1.s.apply(np.imag) 

381 del s1["s"] 

382 s1 = s1.melt( 

383 ignore_index=True, 

384 id_vars=["fb", "tb", "from_name", "to_name", "phase"], 

385 var_name="part", 

386 value_name="s1", 

387 ) 

388 s2 = s2.melt( 

389 ignore_index=True, 

390 id_vars=["fb", "tb", "from_name", "to_name"], 

391 var_name="phase", 

392 value_name="s", 

393 ) 

394 s2["p"] = s2.s.apply(np.real) 

395 s2["q"] = s2.s.apply(np.imag) 

396 del s2["s"] 

397 s2 = s2.melt( 

398 ignore_index=True, 

399 id_vars=["fb", "tb", "from_name", "to_name", "phase"], 

400 var_name="part", 

401 value_name="s2", 

402 ) 

403 s = pd.merge( 

404 s1, s2, on=["fb", "tb", "from_name", "to_name", "phase", "part"], how="outer" 

405 ) 

406 fig = px.bar( 

407 s, 

408 x="to_name", 

409 y=["s1", "s2"], 

410 facet_col="phase", 

411 facet_row="part", 

412 barmode="group", 

413 labels={"to_name": "To-Bus Name"}, 

414 ) 

415 fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1].upper())) 

416 fig.update_layout( 

417 yaxis4_title="Active Power (p.u.)", 

418 yaxis_title="Reactive Power (p.u.)", 

419 ) 

420 return fig 

421 

422 

423def plot_ders(ders: pd.DataFrame) -> go.Figure: 

424 """ 

425 Plot the generated power for each DER. 

426 Parameters 

427 ---------- 

428 ders : pd.DataFrame 

429 

430 Returns 

431 ------- 

432 fig : Plotly figure object 

433 """ 

434 ders = ders.copy() 

435 dec_var = ders.melt( 

436 ignore_index=False, 

437 var_name="phase", 

438 value_name="Generated Power (p.u.)", 

439 id_vars=["id", "name"], 

440 ) 

441 fig = px.bar( 

442 dec_var, 

443 x=dec_var.name, 

444 y="Generated Power (p.u.)", 

445 color="phase", 

446 barmode="group", 

447 labels={"name": "DER Bus Name"}, 

448 ) 

449 fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]).upper()) 

450 return fig 

451 

452 

453def plot_polar(p: pd.DataFrame, q: pd.DataFrame, t=None) -> go.Figure: 

454 p = p.copy() 

455 q = q.copy() 

456 if t is None: 

457 t = 0 

458 if "t" in p.columns: 

459 t1 = t 

460 if t is None and len(p) > 0: 

461 t1 = min(p.t) 

462 p = pd.DataFrame(p.loc[p.t == t1, :]) 

463 p = p.drop("t", axis=1) 

464 if "t" in q.columns: 

465 t2 = t 

466 if t is None and len(q) > 0: 

467 t2 = min(q.t) 

468 q = pd.DataFrame(q.loc[q.t == t2, :]) 

469 q = q.drop("t", axis=1) 

470 

471 p = p.melt( 

472 ignore_index=True, 

473 id_vars=["id", "name"], 

474 var_name="phase", 

475 value_name="value", 

476 ) 

477 q = q.melt( 

478 ignore_index=True, 

479 id_vars=["id", "name"], 

480 var_name="phase", 

481 value_name="value", 

482 ) 

483 pq = pd.merge(p, q, how="outer", on=["id", "name", "phase"]) 

484 pq.id = p.id 

485 pq.name = p.name 

486 pq.phase = p.phase 

487 pq["p"] = p.value 

488 pq["q"] = q.value 

489 pq["s"] = pq.p + 1j * pq.q 

490 pq["r"] = np.abs(pq.s) 

491 pq["th"] = np.angle(pq.s, deg=True) 

492 fig = px.scatter_polar( 

493 pq, 

494 r="r", 

495 theta="th", 

496 color="phase", 

497 # range_theta=[-90, 90], 

498 start_angle=0, 

499 direction="counterclockwise", 

500 hover_data="name", 

501 ) 

502 return fig 

503 

504 

505def plot_batteries(p: pd.DataFrame, soc: pd.DataFrame) -> go.Figure: 

506 df = p.copy() 

507 p_last = df.loc[df.t == df.t.max(), :].copy() 

508 p_last.loc[:, "t"] = df.t.max() + 1 

509 df = pd.concat([df, p_last]).reset_index(drop=True) 

510 soc = soc.copy() 

511 soc.t = soc.t + 1 

512 soc.value = soc.value * 100 

513 df["value"] = df.a + df.b + df.c 

514 df["variable"] = "power" 

515 df.drop(["a", "b", "c"], axis=1) 

516 soc["variable"] = "soc" 

517 df = pd.concat([df, soc]) 

518 fig = px.line( 

519 df, 

520 x="t", 

521 y="value", 

522 facet_row="variable", 

523 color="name", 

524 labels={"name": "Bus Name"}, 

525 ) 

526 for trace in fig.data: 

527 if trace.yaxis == "y2": # Top row uses y2 axis 

528 trace.update(line_shape="hv") 

529 fig.for_each_annotation(lambda a: a.update(text="")) 

530 fig.update_layout( 

531 yaxis2_title="Discharging Power (p.u.)", 

532 yaxis_title="SOC (%)", 

533 yaxis=dict(matches=None), # Make bottom y-axis independent 

534 yaxis2=dict(matches=None), # Make top y-axis independent 

535 ) 

536 

537 return fig 

538 

539 

540def plot_network( 

541 model: LinDistBase | Case, 

542 v: Optional[pd.DataFrame] = None, 

543 s: Optional[pd.DataFrame] = None, 

544 p_flow: Optional[pd.DataFrame] = None, 

545 q_flow: Optional[pd.DataFrame] = None, 

546 p_gen: Optional[pd.DataFrame] = None, 

547 q_gen: Optional[pd.DataFrame] = None, 

548 v_min: float = 0.95, 

549 v_max: float = 1.05, 

550 show_phases: str = "abc", 

551 show_reactive_power: bool = False, 

552 t: Optional[int] = None, 

553) -> go.Figure: 

554 """ 

555 Plot the distribution network showing voltage and power results. 

556 Parameters 

557 ---------- 

558 model : LinDistBase 

559 v : pd.DataFrame, (default=None) Dataframe containing voltage magnitudes for each bus. 

560 s : pd.DataFrame, (default=None) Dataframe containing power flows for each branch. Either s or p_flow and q_flow should be provided. 

561 p_flow : pd.DataFrame, (default=None) Dataframe containing power flows for each branch. 

562 q_flow : pd.DataFrame, (default=None) Dataframe containing power flows for each branch. 

563 p_gen : pd.DataFrame, (default=None) Dataframe containing actve power generated by each generator. 

564 q_gen : pd.DataFrame, (default=None) Dataframe containing reactive power generated by each generator. 

565 v_min : (default=0.95) Used for scaling node colors. 

566 v_max : (default=1.05) Used for scaling node colors. 

567 show_phases : (default="abc") valid options: "a", "b", "c", or "abc" 

568 show_reactive_power : (default=False) If True, show reactive power flows instead of active power flows. 

569 

570 Returns 

571 ------- 

572 fig: plotly.graph_objects.Figure 

573 """ 

574 _v = None 

575 _s = None 

576 if s is not None: 

577 _s = _choose_t(s.copy(), t) 

578 if v is not None: 

579 _v = _choose_t(v.copy(), t) 

580 if s is None and p_flow is not None and q_flow is not None: 

581 from_bus_map = { 

582 int(tb): int(fb) 

583 for fb, tb in model.branch_data.loc[:, ["fb", "tb"]].to_numpy() 

584 } 

585 _s = p_flow.copy() 

586 _s = _s.drop(["a", "b", "c"], axis=1) 

587 _s["a"] = p_flow.a + 1j * q_flow.a 

588 _s["b"] = p_flow.b + 1j * q_flow.b 

589 _s["c"] = p_flow.c + 1j * q_flow.c 

590 _s["tb"] = _s["id"] 

591 _s["fb"] = _s["tb"].map(from_bus_map) 

592 _s = _choose_t(_s, t) 

593 if p_gen is not None: 

594 p_gen = _choose_t(p_gen, t) 

595 if q_gen is not None: 

596 q_gen = _choose_t(q_gen, t) 

597 

598 # validate phases 

599 if show_phases.lower() not in ["a", "b", "c", "abc"]: 

600 raise ValueError("Invalid phase. Must be 'a', 'b', 'c', or 'abc'.") 

601 show_phases = show_phases.lower() 

602 phase_list = sorted([ph.lower() for ph in show_phases]) 

603 bus_data = model.bus_data.copy() 

604 branch_data = model.branch_data.copy() 

605 gen_data = model.gen_data.copy() 

606 cap_data = model.cap_data.copy() 

607 

608 node_size = 10 

609 edge_scale = 10 

610 edge_min = 1 

611 

612 bus_data = _process_bus_data(bus_data, _v, phase_list) 

613 branch_data = _process_branch_data( 

614 branch_data, bus_data, _s, phase_list, edge_scale, edge_min 

615 ) 

616 gen_data = _process_gen_data(gen_data, p_gen, q_gen) 

617 

618 node_trace = _make_node_trace(bus_data, node_size, v_max, v_min) 

619 cap_trace, gen_trace, substation_trace = _make_asset_markers( 

620 bus_data, cap_data, gen_data, node_size 

621 ) 

622 edge_traces = _make_edge_traces(branch_data, show_phases, show_reactive_power) 

623 reverse_flow_markers_trace = _make_reverse_flow_marker_trace( 

624 branch_data, node_size, show_reactive_power 

625 ) 

626 

627 node_trace.text = _make_hover_text(branch_data, bus_data, cap_data, gen_data) 

628 title = _make_title(show_phases, show_reactive_power) 

629 

630 fig = go.Figure( 

631 data=[ 

632 *edge_traces, 

633 substation_trace, 

634 reverse_flow_markers_trace, 

635 cap_trace, 

636 gen_trace, 

637 node_trace, 

638 ] 

639 ) 

640 

641 fig.update_layout( 

642 title=title, 

643 plot_bgcolor="White", 

644 paper_bgcolor="White", 

645 title_font_color="Black", 

646 legend_font_color="Black", 

647 legend_bgcolor="White", 

648 margin=dict(t=50, b=10, l=10, r=10), 

649 xaxis=dict(visible=False), 

650 yaxis=dict(visible=False), 

651 legend=dict(x=0.8, y=0.9), 

652 font=dict(family="Droid Sans Mono", color="Black"), 

653 # annotations=arrow_list[0:1], 

654 ) 

655 

656 return fig 

657 

658 

659def _process_bus_data(bus_data, _v, phase_list): 

660 bus_data["y"] = bus_data.latitude - bus_data.latitude.mean() 

661 bus_data["x"] = bus_data.longitude - bus_data.longitude.mean() 

662 bus_data["color"] = "white" 

663 if bus_data.x.abs().max() > 0: 

664 bus_data.x = bus_data.x / bus_data.x.abs().max() 

665 if bus_data.y.abs().max() > 0: 

666 bus_data.y = bus_data.y / bus_data.y.abs().max() 

667 if _v is not None: 

668 _v.index = _v.id - 1 

669 bus_data.v_a = _v.a 

670 bus_data.v_b = _v.b 

671 bus_data.v_c = _v.c 

672 bus_data.color = _v[phase_list].mean(axis=1) 

673 return bus_data 

674 

675 

676def _process_branch_data(branch_data, bus_data, _s, phase_list, edge_scale, edge_min): 

677 branch_data = branch_data.loc[branch_data.status != "OPEN", :] 

678 branch_data.index = branch_data.tb.to_numpy() - 1 

679 branch_data["s_a"] = complex(np.nan, np.nan) 

680 branch_data["s_b"] = complex(np.nan, np.nan) 

681 branch_data["s_c"] = complex(np.nan, np.nan) 

682 branch_data["p_abs"] = 1 

683 branch_data["q_abs"] = 1 

684 branch_data["p_norm"] = edge_min 

685 branch_data["q_norm"] = edge_min 

686 branch_data["p_direction"] = 1 

687 branch_data["q_direction"] = 1 

688 branch_data["x0"] = bus_data.loc[branch_data.fb.to_numpy() - 1].x.to_numpy() 

689 branch_data["x1"] = bus_data.loc[branch_data.tb.to_numpy() - 1].x.to_numpy() 

690 branch_data["y0"] = bus_data.loc[branch_data.fb.to_numpy() - 1].y.to_numpy() 

691 branch_data["y1"] = bus_data.loc[branch_data.tb.to_numpy() - 1].y.to_numpy() 

692 branch_data["x_mid"] = 1 / 2 * (branch_data.x0 + branch_data.x1) 

693 branch_data["y_mid"] = 1 / 2 * (branch_data.y0 + branch_data.y1) 

694 if _s is not None: 

695 _s.index = _s.tb.to_numpy() - 1 

696 branch_data["s_a"] = _s.a 

697 branch_data["s_b"] = _s.b 

698 branch_data["s_c"] = _s.c 

699 branch_data["p_abs"] = np.abs(np.real(_s.loc[:, phase_list].sum(axis=1))) 

700 branch_data["q_abs"] = np.abs(np.imag(_s.loc[:, phase_list].sum(axis=1))) 

701 branch_data["p_norm"] = ( 

702 branch_data["p_abs"].to_numpy() / branch_data["p_abs"].max() * edge_scale 

703 + edge_min 

704 ) 

705 branch_data["q_norm"] = ( 

706 branch_data["q_abs"].to_numpy() / branch_data["q_abs"].max() * edge_scale 

707 + edge_min 

708 ) 

709 branch_data["p_direction"] = np.sign( 

710 np.real(_s.loc[:, phase_list].sum(axis=1)) + 1e-6 

711 ) 

712 branch_data["q_direction"] = np.sign( 

713 np.imag(_s.loc[:, phase_list].sum(axis=1)) + 1e-6 

714 ) 

715 return branch_data 

716 

717 

718def _process_gen_data(gen_data, p_gen, q_gen): 

719 gen_data.index = gen_data.id.to_numpy() - 1 

720 if p_gen is not None: 

721 p_gen.index = p_gen.id.to_numpy() - 1 

722 gen_data.pa = p_gen.a 

723 gen_data.pb = p_gen.b 

724 gen_data.pc = p_gen.c 

725 if q_gen is not None: 

726 q_gen.index = q_gen.id.to_numpy() - 1 

727 gen_data.qa = q_gen.a 

728 gen_data.qb = q_gen.b 

729 gen_data.qc = q_gen.c 

730 return gen_data 

731 

732 

733def _make_reverse_flow_marker_trace(branch_data, node_size, show_reactive_power): 

734 if show_reactive_power: 

735 reverse_list_x = branch_data.loc[ 

736 branch_data.q_direction < 0, "x_mid" 

737 ].to_numpy() 

738 reverse_list_y = branch_data.loc[ 

739 branch_data.q_direction < 0, "y_mid" 

740 ].to_numpy() 

741 else: 

742 reverse_list_x = branch_data.loc[ 

743 branch_data.p_direction < 0, "x_mid" 

744 ].to_numpy() 

745 reverse_list_y = branch_data.loc[ 

746 branch_data.p_direction < 0, "y_mid" 

747 ].to_numpy() 

748 reverse_flow_markers_trace = go.Scatter() 

749 if not show_reactive_power: 

750 reverse_flow_markers_trace = go.Scatter( 

751 x=reverse_list_x, 

752 y=reverse_list_y, 

753 mode="markers", 

754 marker=dict( 

755 symbol="star-triangle-up", 

756 size=node_size * 1, 

757 color="orange", 

758 line_width=0.5, 

759 line_color="black", 

760 ), 

761 showlegend=True, 

762 name="Reverse Power Flow", 

763 hoverinfo="none", 

764 ) 

765 return reverse_flow_markers_trace 

766 

767 

768def _make_edge_traces(branch_data, show_phases, show_reactive_power): 

769 edge_traces = [] 

770 for _, edge in branch_data.iterrows(): 

771 x0, x1 = edge.x0, edge.x1 

772 y0, y1 = edge.y0, edge.y1 

773 dash = "solid" 

774 linewidth = edge.p_norm 

775 direction = edge.p_direction 

776 if show_reactive_power: 

777 linewidth = edge.q_norm 

778 direction = edge.q_direction 

779 if ( 

780 show_phases.lower() != "abc" 

781 and show_phases.lower() not in edge.phases.lower() 

782 ): 

783 dash = "dot" 

784 

785 color = "darkblue" 

786 if direction < 0: 

787 color = "maroon" 

788 edge_trace = go.Scatter( 

789 x=[x0, x1], 

790 y=[y0, y1], 

791 mode="lines", 

792 line=dict(color=color, width=linewidth, dash=dash), 

793 showlegend=False, 

794 hoverinfo="none", 

795 ) 

796 edge_traces.append(edge_trace) 

797 return edge_traces 

798 

799 

800# def _make_hover_text(branch_data, bus_data, cap_data, gen_data): 

801# text = [f"Bus: '{name}' A || B || C" for name in bus_data["name"]] 

802# for i, bus_row in enumerate(bus_data.itertuples()): 

803# # if _v is not None: 

804# va = bus_row.v_a 

805# vb = bus_row.v_b 

806# vc = bus_row.v_c 

807# text[i] = text[i] + f"<br> |V|: {va:.3f} {vb:.3f} {vc:.3f}" 

808 

809# pla = bus_row.pl_a 

810# plb = bus_row.pl_b 

811# plc = bus_row.pl_c 

812# qla = bus_row.ql_a 

813# qlb = bus_row.ql_b 

814# qlc = bus_row.ql_c 

815# text[i] += f"<br> P-Load: {pla:.3f} {plb:.3f} {plc:.3f}" 

816# text[i] += f"<br> Q-Load: {qla:.3f} {qlb:.3f} {qlc:.3f}" 

817 

818# if cap_data is not None: 

819# if bus_row.id in cap_data.id.to_numpy(): 

820# q_cap = cap_data.loc[ 

821# cap_data.id == bus_row.id, ["qa", "qb", "qc"] 

822# ].to_numpy()[0] 

823# text[i] += ( 

824# f"<br> Q-Cap: {q_cap[0]:.3f} {q_cap[1]:.3f} {q_cap[2]:.3f}" 

825# ) 

826 

827# if bus_row.id in gen_data.id.to_numpy(): 

828# p_gen = gen_data.loc[ 

829# gen_data.id == bus_row.id, ["pa", "pb", "pc"] 

830# ].to_numpy()[0] 

831# q_gen = gen_data.loc[ 

832# gen_data.id == bus_row.id, ["qa", "qb", "qc"] 

833# ].to_numpy()[0] 

834# text[i] += f"<br> P-Gen: {p_gen[0]:.3f} {p_gen[1]:.3f} {p_gen[2]:.3f}" 

835# text[i] += f"<br> Q-Gen: {q_gen[0]:.3f} {q_gen[1]:.3f} {q_gen[2]:.3f}" 

836# edge = branch_data.loc[branch_data.tb == bus_row.id, :] 

837# if len(edge) == 0: 

838# continue 

839# to_name = bus_row.name 

840# fb = edge.fb.to_numpy()[0] 

841# from_name = bus_data.loc[bus_data.id == fb, "name"].to_numpy()[0] 

842# sa, sb, sc = ( 

843# edge.s_a.to_numpy()[0], 

844# edge.s_b.to_numpy()[0], 

845# edge.s_c.to_numpy()[0], 

846# ) 

847# new_text = ( 

848# f"<br>Branch {from_name}→{to_name}" 

849# f"<br> P flow: {np.real(sa):.3f} {np.real(sb):.3f} {np.real(sc):.3f}" 

850# f"<br> Q flow: {np.imag(sa):.3f} {np.imag(sb):.3f} {np.imag(sc):.3f}" 

851# ) 

852# text[i] += new_text 

853# return text 

854 

855 

856def _make_hover_text(branch_data, bus_data, cap_data, gen_data): 

857 text = [] 

858 for i, bus_row in enumerate(bus_data.itertuples()): 

859 bus_phases = bus_row.phases.lower() # e.g. "abc", "ac", "ab", etc. 

860 

861 # Helper function to format value or dash with better alignment 

862 def format_phase_value(value, phase): 

863 if phase in bus_phases: 

864 return f"{value:>7.3f}" # Increased width for better alignment 

865 else: 

866 return " --- " # Centered dashes with proper spacing 

867 

868 # Start with bus header 

869 hover_text = "" 

870 hover_text = f"<b> Bus: {bus_row.name:<5} (p.u.)</b><br>" 

871 # Create a formatted table-like structure using spaces and HTML 

872 hover_text += "<b> A B C</b><br>" # Adjusted spacing 

873 # hover_text += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br>" 

874 

875 # Voltage data 

876 va, vb, vc = bus_row.v_a, bus_row.v_b, bus_row.v_c 

877 va_str = format_phase_value(va, "a") 

878 vb_str = format_phase_value(vb, "b") 

879 vc_str = format_phase_value(vc, "c") 

880 hover_text += f"⚡ V Mag {va_str} {vb_str} {vc_str}<br>" 

881 

882 # Load data 

883 pla, plb, plc = bus_row.pl_a, bus_row.pl_b, bus_row.pl_c 

884 qla, qlb, qlc = bus_row.ql_a, bus_row.ql_b, bus_row.ql_c 

885 pla_str = format_phase_value(pla, "a") 

886 plb_str = format_phase_value(plb, "b") 

887 plc_str = format_phase_value(plc, "c") 

888 qla_str = format_phase_value(qla, "a") 

889 qlb_str = format_phase_value(qlb, "b") 

890 qlc_str = format_phase_value(qlc, "c") 

891 hover_text += f"💡 P Load {pla_str} {plb_str} {plc_str}<br>" 

892 hover_text += f"💡 Q Load {qla_str} {qlb_str} {qlc_str}<br>" 

893 

894 # Capacitor data (if present) 

895 if cap_data is not None and bus_row.id in cap_data.id.to_numpy(): 

896 q_cap = cap_data.loc[ 

897 cap_data.id == bus_row.id, ["qa", "qb", "qc"] 

898 ].to_numpy()[0] 

899 qcap_a_str = format_phase_value(q_cap[0], "a") 

900 qcap_b_str = format_phase_value(q_cap[1], "b") 

901 qcap_c_str = format_phase_value(q_cap[2], "c") 

902 hover_text += f"🔃 Q Cap {qcap_a_str} {qcap_b_str} {qcap_c_str}<br>" 

903 

904 # Generator data (if present) 

905 if bus_row.id in gen_data.id.to_numpy(): 

906 p_gen = gen_data.loc[ 

907 gen_data.id == bus_row.id, ["pa", "pb", "pc"] 

908 ].to_numpy()[0] 

909 q_gen = gen_data.loc[ 

910 gen_data.id == bus_row.id, ["qa", "qb", "qc"] 

911 ].to_numpy()[0] 

912 pgen_a_str = format_phase_value(p_gen[0], "a") 

913 pgen_b_str = format_phase_value(p_gen[1], "b") 

914 pgen_c_str = format_phase_value(p_gen[2], "c") 

915 qgen_a_str = format_phase_value(q_gen[0], "a") 

916 qgen_b_str = format_phase_value(q_gen[1], "b") 

917 qgen_c_str = format_phase_value(q_gen[2], "c") 

918 hover_text += f"⚙️ P Gen {pgen_a_str} {pgen_b_str} {pgen_c_str}<br>" 

919 hover_text += f"⚙️ Q Gen {qgen_a_str} {qgen_b_str} {qgen_c_str}<br>" 

920 

921 # Branch flow data (if present) 

922 edge = branch_data.loc[branch_data.tb == bus_row.id, :] 

923 if len(edge) > 0: 

924 to_name = bus_row.name 

925 fb = edge.fb.to_numpy()[0] 

926 from_name = bus_data.loc[bus_data.id == fb, "name"].to_numpy()[0] 

927 sa, sb, sc = ( 

928 edge.s_a.to_numpy()[0], 

929 edge.s_b.to_numpy()[0], 

930 edge.s_c.to_numpy()[0], 

931 ) 

932 

933 pflow_a_str = format_phase_value(np.real(sa), "a") 

934 pflow_b_str = format_phase_value(np.real(sb), "b") 

935 pflow_c_str = format_phase_value(np.real(sc), "c") 

936 qflow_a_str = format_phase_value(np.imag(sa), "a") 

937 qflow_b_str = format_phase_value(np.imag(sb), "b") 

938 qflow_c_str = format_phase_value(np.imag(sc), "c") 

939 

940 hover_text += "<br>" 

941 hover_text += f"<b> Branch: {from_name}{to_name}</b><br>" 

942 hover_text += f"➡️ P Flow {pflow_a_str} {pflow_b_str} {pflow_c_str}<br>" 

943 hover_text += f"➡️ Q Flow {qflow_a_str} {qflow_b_str} {qflow_c_str}<br>" 

944 text.append(hover_text) 

945 return text 

946 

947 

948def _make_title(show_phases, show_reactive_power): 

949 title = "<b>Network Plot (P.U.)</b>" 

950 # if _v is not None: 

951 title = title + "<br>Node color: " 

952 if show_phases == "abc": 

953 title = title + "Average voltage magnitude" 

954 else: 

955 title = title + f"Phase-{show_phases.upper()} voltage magnitude" 

956 # if _s is not None: 

957 title = title + "<br>Line width:" 

958 if show_phases == "abc": 

959 title = title + " Total" 

960 else: 

961 title = title + f" Phase-{show_phases.upper()}" 

962 if show_reactive_power: 

963 title = title + " <i>reactive</i> power flow" 

964 else: 

965 title = title + " <i>active</i> power flow" 

966 title = title + " (reverse flow in red)." 

967 return title 

968 

969 

970def _make_asset_markers(bus_data, cap_data, gen_data, node_size): 

971 # Add substation marker 

972 substation_buses = bus_data.loc[bus_data.bus_type == "SWING", :] 

973 substation_trace = go.Scatter( 

974 x=substation_buses["x"], 

975 y=substation_buses["y"], 

976 mode="markers", 

977 marker=dict( 

978 symbol="square", 

979 size=node_size * 2, 

980 color="black", 

981 line_width=1, 

982 line_color="black", 

983 ), 

984 showlegend=True, 

985 name="Substation", 

986 hoverinfo="none", 

987 ) 

988 # Add generator markers 

989 gen_buses = bus_data.loc[bus_data.id.isin(gen_data.id), :] 

990 gen_trace = go.Scatter( 

991 x=gen_buses["x"], 

992 y=gen_buses["y"], 

993 mode="markers", 

994 marker=dict( 

995 symbol="square", 

996 size=node_size * 2, 

997 color="white", 

998 line_width=1, 

999 line_color="black", 

1000 ), 

1001 showlegend=True, 

1002 name="Generators", 

1003 hoverinfo="none", 

1004 ) 

1005 # Add capacitor markers 

1006 cap_buses = bus_data.loc[bus_data.id.isin(cap_data.id), :] 

1007 cap_trace = go.Scatter( 

1008 x=cap_buses["x"], 

1009 y=cap_buses["y"], 

1010 mode="markers", 

1011 marker=dict( 

1012 symbol="star-diamond", 

1013 size=node_size * 2, 

1014 color="white", 

1015 line_width=1, 

1016 line_color="black", 

1017 ), 

1018 showlegend=True, 

1019 name="Capacitors", 

1020 hoverinfo="none", 

1021 ) 

1022 return cap_trace, gen_trace, substation_trace 

1023 

1024 

1025def _make_node_trace(bus_data, node_size, v_max, v_min): 

1026 node_trace = go.Scatter( 

1027 x=bus_data["x"], 

1028 y=bus_data["y"], 

1029 mode="markers", 

1030 marker=dict( 

1031 showscale=True, 

1032 cmin=v_min, 

1033 cmax=v_max, 

1034 # colorscale options 

1035 # 'Greys' | 'YlGnBu' | 'Greens' | 'YlOrRd' | 'Bluered' | 'RdBu' | 

1036 # 'Reds' | 'Blues' | 'Picnic' | 'Rainbow' | 'Portland' | 'Jet' | 

1037 # 'Hot' | 'Blackbody' | 'Earth' | 'Electric' | 'Viridis' | 'Aggrnyl' 

1038 colorscale="Viridis", 

1039 reversescale=False, 

1040 size=node_size, 

1041 color=bus_data.color, 

1042 line_width=node_size / 5, 

1043 line_color="black", 

1044 ), 

1045 showlegend=False, 

1046 text=bus_data["name"], 

1047 hovertemplate="%{text}", 

1048 hoverlabel=dict(font=dict(family="Monospace")), 

1049 textposition="top center", 

1050 ) 

1051 return node_trace