Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pdnkit/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions pdnkit/MainWindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}

1 change: 1 addition & 0 deletions pdnkit/MainWindow.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ private slots:
void onExportResultsCsv();
void onSaveModifiedStackup();
void onExportReducedSpice();
void onGenerateReport();
void onExportTouchstone();
void onAboutDialog();
void onShortcutsDialog();
Expand Down
237 changes: 237 additions & 0 deletions pdnkit/Report.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
#include "Report.h"

#include <algorithm>
#include <cmath>
#include <format>
#include <fstream>
#include <sstream>

namespace pdnkit {

namespace {

std::string esc(const std::string& s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
switch (c) {
case '<': out += "&lt;"; break;
case '>': out += "&gt;"; break;
case '&': out += "&amp;"; break;
default: out += c;
}
}
return out;
}

std::string fmt_mv(double v) {
return std::format("{:.3f} mV", v * 1000.0);
}

// Tiny inline SVG line plot for log-frequency Z(f) sweep.
std::string svg_zf_plot(const std::vector<double>& freqs,
const std::vector<std::complex<double>>& zs,
double target_ohm) {
if (freqs.empty() || zs.empty() || freqs.size() != zs.size()) return "";
constexpr int W = 640, H = 240, mx = 60, my = 30;
double f_lo = freqs.front(), f_hi = freqs.back();
double z_lo = 1.0, z_hi = 0.001;
for (auto z : zs) {
const double m = std::abs(z);
if (m > 0.0 && m < z_lo) z_lo = m;
if (m > z_hi) z_hi = m;
}
if (target_ohm > 0.0) {
if (target_ohm < z_lo) z_lo = target_ohm;
if (target_ohm > z_hi) z_hi = target_ohm;
}
z_lo *= 0.5;
z_hi *= 2.0;
const double log_f_lo = std::log10(f_lo), log_f_hi = std::log10(f_hi);
const double log_z_lo = std::log10(z_lo), log_z_hi = std::log10(z_hi);
auto px = [&](double f) {
return mx + (W - 2 * mx) * (std::log10(f) - log_f_lo) /
(log_f_hi - log_f_lo);
};
auto py = [&](double m) {
return H - my - (H - 2 * my) * (std::log10(m) - log_z_lo) /
(log_z_hi - log_z_lo);
};

std::ostringstream s;
s << "<svg width=\"" << W << "\" height=\"" << H << "\""
<< " xmlns=\"http://www.w3.org/2000/svg\""
<< " style=\"background:#1e1e22;\">";
// Border + axes ticks.
s << "<rect x=\"" << mx << "\" y=\"" << my << "\""
<< " width=\"" << (W - 2 * mx) << "\" height=\"" << (H - 2 * my) << "\""
<< " fill=\"none\" stroke=\"#666\"/>";
// Target line.
if (target_ohm > 0.0) {
const double y = py(target_ohm);
s << "<line x1=\"" << mx << "\" y1=\"" << y << "\""
<< " x2=\"" << (W - mx) << "\" y2=\"" << y << "\""
<< " stroke=\"#ff8888\" stroke-dasharray=\"4 4\"/>";
s << "<text x=\"" << (W - mx + 4) << "\" y=\"" << (y + 4) << "\""
<< " fill=\"#ff8888\" font-size=\"11\" font-family=\"monospace\">"
<< std::format("{:.3g} ohm", target_ohm) << "</text>";
}
// Curve.
s << "<polyline fill=\"none\" stroke=\"#fde725\" stroke-width=\"1.5\" points=\"";
for (std::size_t i = 0; i < freqs.size(); ++i) {
if (i > 0) s << " ";
s << px(freqs[i]) << "," << py(std::abs(zs[i]));
}
s << "\"/>";
// Axis labels.
s << "<text x=\"" << mx << "\" y=\"" << (H - 8) << "\""
<< " fill=\"#aaa\" font-size=\"11\" font-family=\"monospace\">"
<< std::format("{:.1e} Hz", f_lo) << "</text>";
s << "<text x=\"" << (W - mx - 60) << "\" y=\"" << (H - 8) << "\""
<< " fill=\"#aaa\" font-size=\"11\" font-family=\"monospace\">"
<< std::format("{:.1e} Hz", f_hi) << "</text>";
s << "<text x=\"4\" y=\"" << (my + 12) << "\""
<< " fill=\"#aaa\" font-size=\"11\" font-family=\"monospace\">"
<< std::format("{:.1e}", z_hi) << "</text>";
s << "<text x=\"4\" y=\"" << (H - my) << "\""
<< " fill=\"#aaa\" font-size=\"11\" font-family=\"monospace\">"
<< std::format("{:.1e}", z_lo) << "</text>";
s << "</svg>";
return s.str();
}

} // namespace

std::string render_signoff_html(const SignoffReport& r) {
std::ostringstream out;
out << R"(<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>pdnkit PDN signoff</title>
<style>
body { font-family: -apple-system, "Segoe UI", sans-serif;
background: #1e1e22; color: #ddd; margin: 24px; max-width: 980px; }
h1 { color: #fde725; border-bottom: 1px solid #444; padding-bottom: 6px; }
h2 { color: #61dafb; margin-top: 28px; }
table { border-collapse: collapse; margin: 8px 0; }
th, td { padding: 4px 12px; border-bottom: 1px solid #333; text-align: left; }
th { background: #2a2a30; }
.bad { color: #ff8888; }
.good { color: #aaffaa; }
.dim { color: #888; }
code { background: #2a2a30; padding: 1px 4px; border-radius: 3px; }
</style></head><body>
)";
out << "<h1>pdnkit PDN signoff</h1>\n";

// ---- Board ----
out << "<h2>Board</h2><table>";
out << "<tr><th>File</th><td><code>" << esc(r.board_path) << "</code></td></tr>";
if (r.board) {
int copper_layers = 0;
for (const auto& L : r.board->stackup.layers)
if (L.is_copper()) ++copper_layers;
out << "<tr><th>Layers (copper / total)</th><td>"
<< copper_layers << " / " << r.board->stackup.layers.size()
<< "</td></tr>";
out << "<tr><th>Nets</th><td>" << r.board->nets.size() << "</td></tr>";
out << "<tr><th>Pads</th><td>" << r.board->pads.size() << "</td></tr>";
out << "<tr><th>Segments</th><td>" << r.board->segments.size()
<< "</td></tr>";
out << "<tr><th>Vias</th><td>" << r.board->vias.size() << "</td></tr>";
out << "<tr><th>Zones</th><td>" << r.board->zones.size() << "</td></tr>";
}
out << "</table>\n";

// ---- IR drop ----
if (r.ir_mesh && r.ir_solution && r.ir_solution->ok) {
out << "<h2>Static IR drop</h2><table>";
const double dv_mv = (r.ir_solution->max_v - r.ir_solution->min_v)
* 1000.0;
out << "<tr><th>Mesh</th><td>" << r.ir_mesh->nodes.size()
<< " nodes, " << r.ir_mesh->resistors.size()
<< " resistors</td></tr>";
out << "<tr><th>V max</th><td>" << fmt_mv(r.ir_solution->max_v)
<< "</td></tr>";
out << "<tr><th>V min</th><td>" << fmt_mv(r.ir_solution->min_v)
<< "</td></tr>";
out << "<tr><th>Drop</th><td><b>"
<< std::format("{:.3f} mV", dv_mv) << "</b></td></tr>";
if (r.thermal && r.thermal->converged) {
out << "<tr><th>Thermal coupled</th><td>"
<< "&Delta;T = "
<< std::format("{:.2f}", r.thermal->final_delta_t_c)
<< " &deg;C ("
<< r.thermal->iterations << " iterations"
<< ", rho up "
<< std::format("{:.2f}", (r.thermal->final_rho / 1.68e-8 - 1.0)
* 100.0) << "%)"
<< "</td></tr>";
}
out << "</table>\n";
} else {
out << "<h2>Static IR drop</h2><p class=\"dim\">No analysis run.</p>\n";
}

// ---- DRC ----
if (r.drc) {
out << "<h2>IPC-2152 DRC</h2>";
out << "<p>" << r.drc->segments_checked << " segments checked across "
<< r.drc->nets_checked << " net(s). ";
if (r.drc->violations.empty()) {
out << "<span class=\"good\">No violations.</span></p>\n";
} else {
out << "<span class=\"bad\">" << r.drc->violations.size()
<< " violation(s):</span></p>";
out << "<table><tr><th>Seg</th><th>Net</th><th>Layer</th>"
<< "<th>Width (mm)</th><th>Required (mm)</th></tr>";
for (const auto& v : r.drc->violations) {
out << "<tr><td>" << v.segment_index << "</td>"
<< "<td>" << v.net_id << "</td>"
<< "<td>" << v.layer_ordinal << "</td>"
<< "<td>"
<< std::format("{:.3f}", v.width_actual_m * 1000.0)
<< "</td>"
<< "<td class=\"bad\">"
<< std::format("{:.3f}", v.width_required_m * 1000.0)
<< "</td></tr>";
}
out << "</table>\n";
}
}

// ---- Z(f) ----
if (!r.zf_freqs.empty()) {
out << "<h2>Plane Z(f)</h2>";
out << svg_zf_plot(r.zf_freqs, r.zf_z, r.zf_target_ohm) << "\n";
double peak = 0.0;
double peak_f = 0.0;
for (std::size_t i = 0; i < r.zf_z.size(); ++i) {
const double m = std::abs(r.zf_z[i]);
if (m > peak) { peak = m; peak_f = r.zf_freqs[i]; }
}
out << "<p>Peak |Z| = <b>"
<< std::format("{:.4g} ohm", peak)
<< "</b> 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) << " &mdash; "
<< "<span class=\"" << (over ? "bad" : "good") << "\">"
<< (over ? "exceeds target" : "passes target") << "</span>";
}
out << ".</p>\n";
}

out << "<p class=\"dim\">Generated by pdnkit.</p>\n";
out << "</body></html>\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
53 changes: 53 additions & 0 deletions pdnkit/Report.h
Original file line number Diff line number Diff line change
@@ -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 <complex>
#include <optional>
#include <string>
#include <vector>

#include "circuitcore/board/Board.h"
#include "pi/IrMesher.h"
#include "pi/IrSolver.h"
#include "pi/PowerDrc.h"
#include "pi/Thermal.h"

namespace pdnkit {

struct SignoffReport {
// Required: the parsed board the analysis ran on.
const circuitcore::board::Board* board = nullptr;
std::string board_path;

// Optional: most recent IR-drop result. Empty mesh means "no IR
// result; skip that section."
const pi::IrMesh* ir_mesh = nullptr;
const pi::Solution* ir_solution = nullptr;
std::optional<pi::ThermalResult> thermal;

// Optional: most recent DRC report.
std::optional<pi::DrcReport> drc;

// Optional: most recent Z(f) sweep -- pair-parallel vectors.
std::vector<double> zf_freqs;
std::vector<std::complex<double>> zf_z;
double zf_target_ohm = 0.0;
};

// Serialize the report as a single-file HTML document. Self-contained
// (inline CSS, inline SVG) so it works without any external assets.
std::string render_signoff_html(const SignoffReport& r);

// Convenience: write to file. Returns true on success.
bool write_signoff_html(const SignoffReport& r, const std::string& path);

} // namespace pdnkit
1 change: 1 addition & 0 deletions pdnkit/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ add_executable(pdnkit_tests
roughness_test.cpp
sensitivity_test.cpp
mor_test.cpp
report_test.cpp
)
target_compile_definitions(pdnkit_tests PRIVATE
PDNKIT_TEST_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures"
Expand Down
Loading
Loading