Power inference: classify rail+control names as power-adjacent, not suspect

Names holding both a rail token (VCC/VDD/PWR/...) and a control token
(SENSE, EN, PG, FB, OK, FAULT, ...) are signals ABOUT a rail - feedback,
enable, power-good - so their non-Power classification is confident.
They used to land in the Suspect bucket, drowning the genuine ambiguities.

- classify_signal_name(): 3-state name verdict (Rail / PowerAdjacent /
  GndShield / Other) with whole-token matching (trailing digits stripped,
  long lexemes also match as suffix: VSENSE, PWRGOOD, NFAULT).
  infer_signal_type() becomes a thin wrapper, so the dashboard suspect
  count and the export suspect column shrink automatically.
- infer_signal_types(): PowerAdjacent -> Other + new `adjacent` stat,
  before the structural gate (a big-fanout sense net stays Other).
- LoadResult.adjacent rendered by all three consumers (TUI command,
  script engine, wx log) - outputs kept in sync.
- analyze Types tab: new [Pwr-adjacent] rows with the deciding token,
  deliberate sort order (Power, Suspect, Adjacent, Gnd), glossary entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 16:41:19 +02:00
parent c2b1f4c4ae
commit 1943f1f88a
12 changed files with 219 additions and 34 deletions

View File

@@ -36,6 +36,7 @@ LoadResult load_module(System *sys, const std::string &module_name,
r.power = inf.power;
r.gnd = inf.gnd;
r.kept_other = inf.kept_other;
r.adjacent = inf.adjacent;
r.ok = true;
} catch (const std::exception &e) {
r.error = e.what();

View File

@@ -23,6 +23,7 @@ struct LoadResult {
int power = 0; ///< signals inferred Power (name + structure)
int gnd = 0; ///< signals inferred GndShield (name)
int kept_other = 0; ///< name said Power but evidence too weak → kept Other
int adjacent = 0; ///< rail + control token (SENSE/EN/PG/…) → Other, not suspect
};
// Import a module from a netlist/pinout file into `sys`, drop singleton signals,

View File

@@ -189,7 +189,9 @@ private:
+ " singleton/NC signal(s))" : ""));
emit(" types: " + std::to_string(r.power) + " power, "
+ std::to_string(r.gnd) + " gnd, " + std::to_string(r.kept_other)
+ " suspect Power (name only — kept as Other)");
+ " suspect Power (name only — kept as Other), "
+ std::to_string(r.adjacent)
+ " power-adjacent (control — kept as Other)");
return true;
}
if (cmd == "connect" || cmd == "plug") {

View File

@@ -276,13 +276,21 @@ SignalTypeInferenceStats infer_signal_types(System *sys) {
Module *mod = mkv.second;
for (auto &skv : *mod->signals) {
Signal *s = skv.second;
SignalType named = infer_signal_type(s->name);
if (named == SignalType::GndShield) {
NameClassification ncl = classify_signal_name(s->name);
if (ncl.verdict == NameVerdict::GndShield) {
s->type = SignalType::GndShield;
++st.gnd;
continue;
}
if (named == SignalType::Power) {
if (ncl.verdict == NameVerdict::PowerAdjacent) {
// A rail token next to a control token (SENSE, EN, PG, …):
// a signal about a rail, confidently NOT the rail — never
// suspect, whatever the fan-out.
s->type = SignalType::Other;
++st.adjacent;
continue;
}
if (ncl.verdict == NameVerdict::Rail) {
int fanout = (int)s->size();
// Hard rule: a "power" net that touches fewer than three
// pins cannot physically be a rail (a real rail goes to

View File

@@ -62,6 +62,8 @@ struct SignalTypeInferenceStats {
int power = 0; ///< Signals promoted to Power (name + structural).
int gnd = 0; ///< Signals promoted to GndShield (name only).
int kept_other = 0; ///< Name said Power but structural evidence too weak.
int adjacent = 0; ///< Rail token + control token (SENSE/EN/PG/…) →
///< confidently Other, never suspect.
};
// Thresholds used by `infer_signal_types` (re-exposed so the analyze screen

View File

@@ -5,8 +5,24 @@
enum class SignalType { Power, GndShield, Other };
// Name-level verdict, richer than SignalType. `PowerAdjacent` is the key
// addition: a name holding BOTH a rail token (VCC/VDD/PWR/…) and a control
// token (SENSE/EN/PG/FB/…) is a signal *about* a rail — measurement, enable,
// power-good — not the rail itself. Its non-Power classification is therefore
// confident, where a bare rail name without structural evidence stays suspect.
enum class NameVerdict { Rail, GndShield, PowerAdjacent, Other };
struct NameClassification {
NameVerdict verdict = NameVerdict::Other;
std::string token; ///< PowerAdjacent only: the control token that decided it.
};
NameClassification classify_signal_name(const std::string &signal_name);
const char *signal_type_name(SignalType t);
bool signal_type_from_name(const std::string &s, SignalType &out);
// Thin wrapper over classify_signal_name: Rail → Power, GndShield → GndShield,
// PowerAdjacent/Other → Other.
SignalType infer_signal_type(const std::string &signal_name);
SignalType next_signal_type(SignalType t); // Power → GndShield → Other → Power

View File

@@ -4,6 +4,7 @@
#include <algorithm>
#include <cctype>
#include <cstring>
#include <vector>
const char *signal_type_name(SignalType t) {
@@ -36,11 +37,68 @@ bool signal_type_from_name(const std::string &s, SignalType &out) {
return false;
}
// Heuristic. Names like GND, GNDA, SHIELD, CHASSIS → GndShield.
// Names containing PWR/POWER/VCC/VDD/VEE/VSS, or matching V±N or +N.NV
// patterns, or starting with VS_/VS3_ → Power. Else Other.
SignalType infer_signal_type(const std::string &name) {
if (name.empty()) return SignalType::Other;
namespace {
// Control/monitoring vocabulary: a name holding both a rail token and one of
// these is a signal ABOUT a rail (feedback, enable, power-good, fault, …) —
// not the rail itself. Matched against whole separator-delimited tokens
// (uppercase, trailing digits stripped so EN1/PG0 still hit). Entries of
// length ≥ 4 also match as a token *suffix*, catching fused (VSENSE, PWRGOOD)
// and active-low (NFAULT) forms; short entries match exactly, so GREEN or
// SENSOR never trip on EN / SENSE.
const char *const kPowerControlTokens[] = {
"SENSE", "SNS", "KELVIN", // remote / Kelvin sense
"FB", "FDBK", "FEEDBACK", // regulator feedback
"EN", "ENA", "ENABLE", "INH", "INHIBIT", // enable / inhibit
"PG", "PGOOD", "PWRGD", "PWROK", // power-good
"GOOD", "OK", "FAIL", "FAULT", "FLT", // status / fault
"ALERT", "ALRT", "WARN",
"MON", "IMON", "VMON", "PMON", // monitoring
"DET", "DETECT", "PRSNT", "PRESENT", // presence detection
"OC", "OCP", "OV", "OVP", "UV", "UVP", // protection trips
"TRIP", "SHDN", "SHUTDOWN",
"SEQ", "CTRL", "CTL", // sequencing / control
"STAT", "STATUS",
"ON", "OFF", "BTN", // on/off request
"CS", "IRQ",
};
bool is_power_control_token(std::string tok) {
while (!tok.empty() && std::isdigit((unsigned char)tok.back()))
tok.pop_back(); // EN1, PG0, FB2 …
if (tok.empty()) return false;
for (const char *lex : kPowerControlTokens) {
size_t n = std::strlen(lex);
if (tok == lex) return true;
if (n >= 4 && tok.size() > n
&& tok.compare(tok.size() - n, n, lex) == 0)
return true; // VSENSE, PWRGOOD, NFAULT …
}
return false;
}
// Split on every non-alphanumeric character. `u` is already uppercase.
std::vector<std::string> alnum_tokens(const std::string &u) {
std::vector<std::string> out;
std::string cur;
for (char c : u) {
if (std::isalnum((unsigned char)c)) { cur += c; continue; }
if (!cur.empty()) { out.push_back(std::move(cur)); cur.clear(); }
}
if (!cur.empty()) out.push_back(std::move(cur));
return out;
}
} // namespace
// Heuristic. Names like GND, GNDA, SHIELD, CHASSIS → GndShield (name alone is
// reliable there — left out of the control-token logic on purpose). Names
// containing PWR/POWER/VCC/VDD/VEE/VSS, or starting with VS_/VBAT/+/ → rail
// candidates; a rail candidate whose tokens include a control word (SENSE,
// EN, PG, …) is downgraded to PowerAdjacent. Else Other.
NameClassification classify_signal_name(const std::string &name) {
NameClassification out;
if (name.empty()) return out;
std::string u = name;
std::transform(u.begin(), u.end(), u.begin(),
[](unsigned char c) { return std::toupper(c); });
@@ -52,14 +110,14 @@ SignalType infer_signal_type(const std::string &name) {
return u.rfind(needle, 0) == 0;
};
if (u == "GND" || u == "GROUND") return SignalType::GndShield;
if (starts_with("GND_")
if (u == "GND" || u == "GROUND"
|| starts_with("GND_")
|| (starts_with("GND") && u.size() >= 4
&& std::isalpha((unsigned char)u[3]))) {
return SignalType::GndShield;
&& std::isalpha((unsigned char)u[3]))
|| contains("SHIELD") || contains("CHASSIS") || contains("EARTH")) {
out.verdict = NameVerdict::GndShield;
return out;
}
if (contains("SHIELD") || contains("CHASSIS") || contains("EARTH"))
return SignalType::GndShield;
if (contains("PWR") || contains("POWER")
|| contains("VCC") || contains("VDD") || contains("VEE") || contains("VSS")
@@ -67,7 +125,25 @@ SignalType infer_signal_type(const std::string &name) {
|| starts_with("VS3_") || starts_with("VS4_")
|| starts_with("VBAT") || starts_with("VBUS")
|| starts_with("+") || starts_with("-")) {
return SignalType::Power;
for (const std::string &tok : alnum_tokens(u)) {
if (is_power_control_token(tok)) {
out.verdict = NameVerdict::PowerAdjacent;
out.token = tok;
return out;
}
}
out.verdict = NameVerdict::Rail;
return out;
}
return out;
}
SignalType infer_signal_type(const std::string &name) {
switch (classify_signal_name(name).verdict) {
case NameVerdict::Rail: return SignalType::Power;
case NameVerdict::GndShield: return SignalType::GndShield;
case NameVerdict::PowerAdjacent:
case NameVerdict::Other: break;
}
return SignalType::Other;
}

View File

@@ -152,7 +152,9 @@ void Tui::RegisterCommands() {
Print(" types: " + std::to_string(r.power) + " power, "
+ std::to_string(r.gnd) + " gnd, "
+ std::to_string(r.kept_other)
+ " suspect Power (name only — kept as Other)");
+ " suspect Power (name only — kept as Other), "
+ std::to_string(r.adjacent)
+ " power-adjacent (control — kept as Other)");
},
/*prompt_for_missing=*/ true,
"load a module from a netlist / pinout file (mentor, altium, ods)",

View File

@@ -131,29 +131,39 @@ Component Tui::BuildAnalyzeScreen() {
// ============================================================= Types
// Power decisions (confirmed / refuted) and NC orphan breakdown.
analyze_types.clear();
int conf_pwr = 0, ref_pwr = 0, gnd = 0;
struct Row { char kind; std::string mod, sig; int fanout; bool voltage; };
int conf_pwr = 0, ref_pwr = 0, adj = 0, gnd = 0;
struct Row { char kind; std::string mod, sig; int fanout; bool voltage;
std::string token; };
std::vector<Row> rows;
for (auto &mkv : *sys->modules()) {
Module *mod = mkv.second;
for (auto &skv : *mod->signals) {
Signal *s = skv.second;
SignalType named = infer_signal_type(s->name);
NameClassification ncl = classify_signal_name(s->name);
char kind = 0;
if (named == SignalType::GndShield && s->type == SignalType::GndShield) {
std::string token;
if (ncl.verdict == NameVerdict::GndShield && s->type == SignalType::GndShield) {
kind = 'G'; ++gnd;
} else if (named == SignalType::Power && s->type == SignalType::Power) {
} else if (ncl.verdict == NameVerdict::Rail && s->type == SignalType::Power) {
kind = 'P'; ++conf_pwr;
} else if (named == SignalType::Power && s->type == SignalType::Other) {
} else if (ncl.verdict == NameVerdict::Rail && s->type == SignalType::Other) {
kind = 'R'; ++ref_pwr;
} else if (ncl.verdict == NameVerdict::PowerAdjacent) {
kind = 'A'; ++adj; token = ncl.token;
} else continue;
rows.push_back({kind, mod->name, s->name,
(int)s->size(), has_voltage_pattern(s->name)});
(int)s->size(), has_voltage_pattern(s->name),
token});
}
}
// Deliberate display order: confirmed rails, then the suspects (the
// actionable residue), then the power-adjacent controls, gnd last.
auto rank = [](char k) {
return k == 'P' ? 0 : k == 'R' ? 1 : k == 'A' ? 2 : 3;
};
std::sort(rows.begin(), rows.end(),
[](const Row &a, const Row &b) {
if (a.kind != b.kind) return a.kind < b.kind;
[&](const Row &a, const Row &b) {
if (a.kind != b.kind) return rank(a.kind) < rank(b.kind);
if (a.mod != b.mod) return a.mod < b.mod;
return a.sig < b.sig;
});
@@ -181,6 +191,10 @@ Component Tui::BuildAnalyzeScreen() {
else reason = "name only — fan-out "
+ std::to_string(r.fanout)
+ ", no voltage";
} else if (r.kind == 'A') {
tag = "[Pwr-adjacent] ";
reason = "control token '" + r.token
+ "' in name — kept as Other";
} else {
tag = "[Gnd] ";
reason = "name match (fan-out " + std::to_string(r.fanout) + ")";
@@ -200,7 +214,8 @@ Component Tui::BuildAnalyzeScreen() {
std::string types_header = "Types: " + std::to_string(conf_pwr)
+ " Power, " + std::to_string(ref_pwr)
+ " Suspect, " + std::to_string(gnd)
+ " Suspect, " + std::to_string(adj)
+ " Adjacent, " + std::to_string(gnd)
+ " Gnd";
// Tab bar — horizontal headers, active one inverted.
@@ -240,8 +255,13 @@ Component Tui::BuildAnalyzeScreen() {
"Name suggests Power AND structure agrees: fan-out ≥ 4 pins, "
"or a voltage pattern in the name (e.g. 3V3, 5V, 12V)."),
term("Suspect Power",
"Name suggests Power but the structural check failed — "
"fan-out too low and no voltage in the name."),
"Name suggests Power, no control token explains it, but the "
"structural check failed — fan-out too low and no voltage "
"in the name."),
term("Pwr-adjacent",
"Name holds a rail token AND a control token (SENSE, EN, PG, "
"FB, …): a signal about a rail — measurement or control — "
"not the rail itself. Confidently Other, never suspect."),
term("Hard floor",
"Fan-out below 3 pins forces Other regardless of the name. "
"A real rail physically cannot live on 1-2 pads."),

View File

@@ -352,9 +352,9 @@ void EssimFrame::OnLoad(wxCommandEvent &) {
}
Log(wxString::Format(
"loaded '%s' from %s — %d part(s), %d signal(s)"
" (dropped %d; types: %d power / %d gnd / %d suspect)",
" (dropped %d; types: %d power / %d gnd / %d suspect / %d pwr-adjacent)",
modname, path, r.parts, r.signals, r.dropped, r.power, r.gnd,
r.kept_other));
r.kept_other, r.adjacent));
RebuildModelView();
}

View File

@@ -235,7 +235,7 @@ TEST_CASE("infer_signal_types: Power requires name+structural agreement") {
Signal *p_3v3 = m->signals->merge("PWR_3V3"); fan_out(p_3v3, 3); // voltage + ≥ floor → Power
Signal *vcc = m->signals->merge("VCC"); fan_out(vcc, 5); // fan-out ≥ 4 → Power
Signal *pwr_ok = m->signals->merge("PWR_OK"); fan_out(pwr_ok, 1); // < 3 → hard floor → Other
Signal *pwr_ok = m->signals->merge("PWR_OK"); fan_out(pwr_ok, 1); // control token → adjacent
Signal *pwr_2 = m->signals->merge("PWR_2"); fan_out(pwr_2, 2); // < 3 → hard floor → Other
Signal *gnd = m->signals->merge("GND"); fan_out(gnd, 1); // gnd: name alone
Signal *clk = m->signals->merge("CLK_50MHZ"); fan_out(clk, 3); // not power-ish → Other
@@ -243,7 +243,8 @@ TEST_CASE("infer_signal_types: Power requires name+structural agreement") {
auto st = infer_signal_types(sys.get());
CHECK(st.power == 2); // PWR_3V3, VCC
CHECK(st.gnd == 1); // GND (name alone)
CHECK(st.kept_other == 2); // PWR_OK, PWR_2 below the hard floor
CHECK(st.kept_other == 1); // PWR_2 below the hard floor
CHECK(st.adjacent == 1); // PWR_OK: power-good control, not suspect
CHECK(p_3v3->type == SignalType::Power);
CHECK(vcc->type == SignalType::Power);
@@ -253,6 +254,27 @@ TEST_CASE("infer_signal_types: Power requires name+structural agreement") {
CHECK(clk->type == SignalType::Other);
}
TEST_CASE("infer_signal_types: power-adjacent beats fan-out — a big sense net is still Other") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
// VDD_CORE_SENSE with fan-out 5: structure alone would confirm Power,
// but the control token settles it as a measurement net → Other,
// counted adjacent (not suspect, not power).
Signal *s = m->signals->merge("VDD_CORE_SENSE");
for (int i = 0; i < 5; ++i) {
Pin *pin = new Pin("p" + std::to_string(i));
p->add(pin); s->add(pin); pin->connect(s);
}
auto st = infer_signal_types(sys.get());
CHECK(st.power == 0);
CHECK(st.kept_other == 0);
CHECK(st.adjacent == 1);
CHECK(s->type == SignalType::Other);
}
TEST_CASE("infer_signal_types: fan-out hard floor overrides voltage in name") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");

View File

@@ -51,7 +51,42 @@ TEST_CASE("infer_signal_type: power family") {
CHECK(infer_signal_type("VS3_5V0") == SignalType::Power);
CHECK(infer_signal_type("+5V") == SignalType::Power);
CHECK(infer_signal_type("-12V") == SignalType::Power);
CHECK(infer_signal_type("VBAT_SENSE") == SignalType::Power);
// Rail token + control token → power-adjacent, mapped to Other by the
// wrapper (it is a sense line ABOUT VBAT, not the rail).
CHECK(infer_signal_type("VBAT_SENSE") == SignalType::Other);
}
TEST_CASE("classify_signal_name: rail vs power-adjacent control signals") {
CHECK(classify_signal_name("VCC").verdict == NameVerdict::Rail);
CHECK(classify_signal_name("VDD_3V3").verdict == NameVerdict::Rail);
CHECK(classify_signal_name("+5V").verdict == NameVerdict::Rail);
NameClassification c = classify_signal_name("VDD_CORE_SENSE");
CHECK(c.verdict == NameVerdict::PowerAdjacent);
CHECK(c.token == "SENSE");
CHECK(classify_signal_name("VBAT_SENSE").verdict == NameVerdict::PowerAdjacent);
CHECK(classify_signal_name("VCC_EN").verdict == NameVerdict::PowerAdjacent);
CHECK(classify_signal_name("VCC_EN1").verdict == NameVerdict::PowerAdjacent); // trailing digit
CHECK(classify_signal_name("VDD_FB").verdict == NameVerdict::PowerAdjacent);
CHECK(classify_signal_name("PWR_GOOD").verdict == NameVerdict::PowerAdjacent);
CHECK(classify_signal_name("PWR_OK").verdict == NameVerdict::PowerAdjacent);
CHECK(classify_signal_name("VBUS_DET").verdict == NameVerdict::PowerAdjacent);
CHECK(classify_signal_name("POWER_FAIL").verdict == NameVerdict::PowerAdjacent);
CHECK(classify_signal_name("VDD_VSENSE").verdict == NameVerdict::PowerAdjacent); // fused suffix
CHECK(classify_signal_name("PWR_NFAULT").verdict == NameVerdict::PowerAdjacent); // active-low
// Whole-token matching: SENSOR is not SENSE, GREEN is not EN —
// these stay genuine rails.
CHECK(classify_signal_name("VDD_SENSOR").verdict == NameVerdict::Rail);
CHECK(classify_signal_name("VCC_GREEN").verdict == NameVerdict::Rail);
// No rail token at all → Other, even with a control word.
CHECK(classify_signal_name("SPI_CS").verdict == NameVerdict::Other);
CHECK(classify_signal_name("FAN_SENSE").verdict == NameVerdict::Other);
// GND family is deliberately left out of the control-token logic.
CHECK(classify_signal_name("GND_RET").verdict == NameVerdict::GndShield);
}
TEST_CASE("infer_signal_type: other (data signals)") {