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 +#include +#include +#include +#include + +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 += "<"; break; + case '>': out += ">"; break; + case '&': out += "&"; 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& freqs, + const std::vector>& 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 << ""; + // Border + axes ticks. + s << ""; + // Target line. + if (target_ohm > 0.0) { + const double y = py(target_ohm); + s << ""; + s << "" + << std::format("{:.3g} ohm", target_ohm) << ""; + } + // Curve. + s << " 0) s << " "; + s << px(freqs[i]) << "," << py(std::abs(zs[i])); + } + s << "\"/>"; + // Axis labels. + s << "" + << std::format("{:.1e} Hz", f_lo) << ""; + s << "" + << std::format("{:.1e} Hz", f_hi) << ""; + s << "" + << std::format("{:.1e}", z_hi) << ""; + s << "" + << std::format("{:.1e}", z_lo) << ""; + s << ""; + return s.str(); +} + +} // namespace + +std::string render_signoff_html(const SignoffReport& r) { + std::ostringstream out; + out << R"( +pdnkit PDN signoff + +)"; + out << "

pdnkit PDN signoff

\n"; + + // ---- Board ---- + out << "

Board

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

Static IR drop

"; + const double dv_mv = (r.ir_solution->max_v - r.ir_solution->min_v) + * 1000.0; + out << ""; + out << ""; + out << ""; + out << ""; + if (r.thermal && r.thermal->converged) { + out << ""; + } + out << "
Mesh" << r.ir_mesh->nodes.size() + << " nodes, " << r.ir_mesh->resistors.size() + << " resistors
V max" << fmt_mv(r.ir_solution->max_v) + << "
V min" << fmt_mv(r.ir_solution->min_v) + << "
Drop" + << std::format("{:.3f} mV", dv_mv) << "
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) << "%)" + << "
\n"; + } else { + out << "

Static IR drop

No analysis run.

\n"; + } + + // ---- DRC ---- + if (r.drc) { + out << "

IPC-2152 DRC

"; + out << "

" << r.drc->segments_checked << " segments checked across " + << r.drc->nets_checked << " net(s). "; + if (r.drc->violations.empty()) { + out << "No violations.

\n"; + } else { + out << "" << r.drc->violations.size() + << " violation(s):

"; + out << "" + << ""; + for (const auto& v : r.drc->violations) { + out << "" + << "" + << "" + << "" + << ""; + } + out << "
SegNetLayerWidth (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) + << "
\n"; + } + } + + // ---- Z(f) ---- + if (!r.zf_freqs.empty()) { + out << "

Plane Z(f)

"; + 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 << "

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 +#include +#include +#include + +#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 thermal; + + // Optional: most recent DRC report. + std::optional drc; + + // Optional: most recent Z(f) sweep -- pair-parallel vectors. + std::vector zf_freqs; + std::vector> 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 diff --git a/pdnkit/tests/CMakeLists.txt b/pdnkit/tests/CMakeLists.txt index 89aef61..c92d214 100644 --- a/pdnkit/tests/CMakeLists.txt +++ b/pdnkit/tests/CMakeLists.txt @@ -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" diff --git a/pdnkit/tests/report_test.cpp b/pdnkit/tests/report_test.cpp new file mode 100644 index 0000000..57e0cf3 --- /dev/null +++ b/pdnkit/tests/report_test.cpp @@ -0,0 +1,86 @@ +#include + +#include "Report.h" +#include "circuitcore/board/Board.h" + +using namespace pdnkit; + +namespace { +bool has(const std::string& s, const std::string& needle) { + return s.find(needle) != std::string::npos; +} +} + +TEST_CASE("report: minimal board renders board section", "[report]") { + circuitcore::board::Board b; + b.stackup.layers.push_back({0, "F.Cu", "signal", 35.0e-6}); + b.stackup.layers.push_back({31, "B.Cu", "signal", 35.0e-6}); + b.nets.push_back({1, "+3V3"}); + + SignoffReport r; + r.board = &b; + r.board_path = "test.kicad_pcb"; + + auto html = render_signoff_html(r); + REQUIRE(has(html, "pdnkit PDN signoff")); + REQUIRE(has(html, "test.kicad_pcb")); + REQUIRE(has(html, "")); +} + +TEST_CASE("report: IR drop section appears when solution given", + "[report]") { + circuitcore::board::Board b; + pi::IrMesh m; + m.nodes.push_back({0, 0.0, 0.0, 0, 0, 0}); + m.nodes.push_back({1, 0.001, 0.0, 1, 0, 0}); + pi::Solution sol; + sol.ok = true; + sol.voltages = {1.0, 0.95}; + sol.min_v = 0.95; + sol.max_v = 1.0; + SignoffReport r; + r.board = &b; + r.ir_mesh = &m; + r.ir_solution = / + auto html = render_signoff_html(r); + REQUIRE(has(html, "Static IR drop")); + REQUIRE(has(html, "50.000 mV")); // 1.0 - 0.95 = 50 mV drop +} + +TEST_CASE("report: DRC section shows violations or all-clear", + "[report]") { + circuitcore::board::Board b; + SignoffReport r; + r.board = &b; + pi::DrcReport drc; + drc.segments_checked = 5; + drc.nets_checked = 1; + r.drc = drc; + auto html = render_signoff_html(r); + REQUIRE(has(html, "No violations")); + // Add a violation. + pi::DrcViolation v; + v.segment_index = 7; + v.net_id = 2; + v.layer_ordinal = 0; + v.width_actual_m = 0.3e-3; + v.width_required_m = 0.8e-3; + drc.violations.push_back(v); + r.drc = drc; + html = render_signoff_html(r); + REQUIRE(has(html, "violation")); + REQUIRE(has(html, "0.300")); +} + +TEST_CASE("report: Z(f) section embeds an SVG", "[report]") { + circuitcore::board::Board b; + SignoffReport r; + r.board = &b; + r.zf_freqs = {1.0e6, 1.0e7, 1.0e8}; + r.zf_z = {{0.01, 0.0}, {0.05, 0.0}, {0.1, 0.0}}; + r.zf_target_ohm = 0.025; + auto html = render_signoff_html(r); + REQUIRE(has(html, "<svg")); + REQUIRE(has(html, "Peak |Z|")); + REQUIRE(has(html, "exceeds target")); // peak 0.1 > target 0.025 +}