diff --git a/pdnkit/CMakeLists.txt b/pdnkit/CMakeLists.txt
index c83e5a9..9eab502 100644
--- a/pdnkit/CMakeLists.txt
+++ b/pdnkit/CMakeLists.txt
@@ -21,6 +21,7 @@ add_library(pdnkit_pi STATIC
pi/Sensitivity.cpp
pi/Mor.cpp
StackupWriter.cpp
+ Report.cpp
render/Camera2D.cpp
render/ZoneMesher.cpp
render/IrResultMesh.cpp
diff --git a/pdnkit/MainWindow.cpp b/pdnkit/MainWindow.cpp
index 188ffb1..54f3a30 100644
--- a/pdnkit/MainWindow.cpp
+++ b/pdnkit/MainWindow.cpp
@@ -30,6 +30,7 @@
#include "NetStatsPanel.h"
#include "LayerPanel.h"
#include "StackupWriter.h"
+#include "Report.h"
#include "PcbCanvas.h"
#include "circuitcore/formats/kicad/PcbParser.h"
#include "pi/IrMesher.h"
@@ -185,6 +186,9 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
"thicknesses applied. Source file is never overwritten.");
connect(saveStackupAct, &QAction::triggered, this,
&MainWindow::onSaveModifiedStackup);
+ auto* reportAct = fileMenu->addAction("&Generate PDN Report...");
+ connect(reportAct, &QAction::triggered, this,
+ &MainWindow::onGenerateReport);
auto* exportTouchstoneAct = fileMenu->addAction(
"Export &Touchstone (.s1p)...");
connect(exportTouchstoneAct, &QAction::triggered, this,
@@ -879,3 +883,37 @@ void MainWindow::onSaveModifiedStackup() {
10000);
}
+void MainWindow::onGenerateReport() {
+ if (!board_) {
+ QMessageBox::information(this, "Generate PDN Report",
+ "Open a KiCad PCB first.");
+ return;
+ }
+ const QString suggested = current_board_path_.isEmpty()
+ ? QString("pdnkit_report.html")
+ : QFileInfo(current_board_path_).completeBaseName() + "_pdn.html";
+ const QString path = QFileDialog::getSaveFileName(
+ this, "Generate PDN Report", suggested,
+ "HTML (*.html);;All files (*)");
+ if (path.isEmpty()) return;
+
+ pdnkit::SignoffReport r;
+ r.board = board_.get();
+ r.board_path = current_board_path_.toStdString();
+ if (!last_mesh_.nodes.empty() && last_solution_.ok) {
+ r.ir_mesh = &last_mesh_;
+ r.ir_solution = &last_solution_;
+ }
+ if (cavity_panel_ && cavity_panel_->hasLastSweep()) {
+ r.zf_freqs = cavity_panel_->lastSweepFreqs();
+ r.zf_z = cavity_panel_->lastSweepZ();
+ }
+ if (!pdnkit::write_signoff_html(r, path.toStdString())) {
+ QMessageBox::critical(this, "Generate PDN Report",
+ "Failed to write " + path);
+ return;
+ }
+ statusBar()->showMessage(
+ QString("Wrote PDN signoff report to %1").arg(path), 8000);
+}
+
diff --git a/pdnkit/MainWindow.h b/pdnkit/MainWindow.h
index 6f8377e..fc55650 100644
--- a/pdnkit/MainWindow.h
+++ b/pdnkit/MainWindow.h
@@ -43,6 +43,7 @@ private slots:
void onExportResultsCsv();
void onSaveModifiedStackup();
void onExportReducedSpice();
+ void onGenerateReport();
void onExportTouchstone();
void onAboutDialog();
void onShortcutsDialog();
diff --git a/pdnkit/Report.cpp b/pdnkit/Report.cpp
new file mode 100644
index 0000000..0a64d5e
--- /dev/null
+++ b/pdnkit/Report.cpp
@@ -0,0 +1,237 @@
+#include "Report.h"
+
+#include No analysis run. " << r.drc->segments_checked << " segments checked across "
+ << r.drc->nets_checked << " net(s). ";
+ if (r.drc->violations.empty()) {
+ out << "No violations.pdnkit PDN signoff
\n";
+
+ // ---- Board ----
+ out << "Board
";
+ out << "
\n";
+
+ // ---- IR drop ----
+ if (r.ir_mesh && r.ir_solution && r.ir_solution->ok) {
+ out << " ";
+ if (r.board) {
+ int copper_layers = 0;
+ for (const auto& L : r.board->stackup.layers)
+ if (L.is_copper()) ++copper_layers;
+ out << "File " << esc(r.board_path) << " ";
+ out << "Layers (copper / total) "
+ << copper_layers << " / " << r.board->stackup.layers.size()
+ << " ";
+ out << "Nets " << r.board->nets.size() << " ";
+ out << "Pads " << r.board->pads.size() << " ";
+ out << "Segments " << r.board->segments.size()
+ << " ";
+ out << "Vias " << r.board->vias.size() << " ";
+ }
+ out << "Zones " << r.board->zones.size() << " Static IR drop
";
+ const double dv_mv = (r.ir_solution->max_v - r.ir_solution->min_v)
+ * 1000.0;
+ out << "
\n";
+ } else {
+ out << " ";
+ out << "Mesh " << r.ir_mesh->nodes.size()
+ << " nodes, " << r.ir_mesh->resistors.size()
+ << " resistors ";
+ out << "V max " << fmt_mv(r.ir_solution->max_v)
+ << " ";
+ out << "V min " << fmt_mv(r.ir_solution->min_v)
+ << " ";
+ if (r.thermal && r.thermal->converged) {
+ out << "Drop "
+ << std::format("{:.3f} mV", dv_mv) << " ";
+ }
+ out << "Thermal coupled "
+ << "ΔT = "
+ << std::format("{:.2f}", r.thermal->final_delta_t_c)
+ << " °C ("
+ << r.thermal->iterations << " iterations"
+ << ", rho up "
+ << std::format("{:.2f}", (r.thermal->final_rho / 1.68e-8 - 1.0)
+ * 100.0) << "%)"
+ << " Static IR drop
IPC-2152 DRC
";
+ out << "
| Seg | Net | Layer | " + << "Width (mm) | Required (mm) |
|---|---|---|---|---|
| " << v.segment_index << " | " + << "" << v.net_id << " | " + << "" << v.layer_ordinal << " | " + << "" + << std::format("{:.3f}", v.width_actual_m * 1000.0) + << " | " + << "" + << std::format("{:.3f}", v.width_required_m * 1000.0) + << " |
Peak |Z| = " + << std::format("{:.4g} ohm", peak) + << " at " + << std::format("{:.3e} Hz", peak_f); + if (r.zf_target_ohm > 0.0) { + const bool over = peak > r.zf_target_ohm; + out << ", target " + << std::format("{:.4g}", r.zf_target_ohm) << " — " + << "" + << (over ? "exceeds target" : "passes target") << ""; + } + out << ".
\n"; + } + + out << "Generated by pdnkit.
\n"; + out << "\n"; + return out.str(); +} + +bool write_signoff_html(const SignoffReport& r, const std::string& path) { + std::ofstream f(path); + if (!f) return false; + f << render_signoff_html(r); + return f.good(); +} + +} // namespace pdnkit diff --git a/pdnkit/Report.h b/pdnkit/Report.h new file mode 100644 index 0000000..ccd018e --- /dev/null +++ b/pdnkit/Report.h @@ -0,0 +1,53 @@ +// PDN signoff report. +// +// One HTML file summarising the analysis the user has just done: +// board parsed, IR drop, thermal coupling result, DRC violations, +// Z(f) sweep with target line. Hand it to the design reviewer; or +// to your future self when you come back to this board. +// +// Pure data formatting -- no Qt, no GUI. Anything that can be cheaply +// reduced to text or a small SVG fragment lands here. + +#pragma once + +#include