Skip to main content

pedalkernel_layout/
groups.rs

1//! Phase 2: Functional group detection.
2//!
3//! Automatically identifies circuit subcircuits by pattern matching on the
4//! layout graph. Groups include gain stages, tone stacks, push-pull outputs,
5//! phase inverters, and generic groups for unmatched components.
6
7use crate::graph::LayoutGraph;
8use std::collections::BTreeSet;
9
10/// The type of functional group detected.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum GroupKind {
13    /// Triode/BJT/JFET gain stage: active device + plate/collector load +
14    /// optional cathode/emitter bias + coupling caps.
15    GainStage,
16    /// Tone stack: cluster of pots + caps + resistors between gain stages.
17    ToneStack,
18    /// Push-pull output: 2 or 4 pentodes driving a transformer.
19    PushPullOutput,
20    /// Phase inverter: two triodes sharing a tail resistor.
21    PhaseInverter,
22    /// Op-amp stage: op-amp with feedback network.
23    OpAmpStage,
24    /// Input section: components connected directly to the input.
25    InputSection,
26    /// Output section: components connected directly to the output.
27    OutputSection,
28    /// Generic group for components that don't match a specific pattern.
29    Generic,
30}
31
32/// A detected functional group.
33#[derive(Debug, Clone)]
34pub struct FunctionalGroup {
35    /// Unique group name (e.g., `"gain_stage_1"`, `"tone_stack"`).
36    pub name: String,
37    /// Human-readable label (e.g., `"V1a Preamp"`, `"Tone Stack"`).
38    pub label: String,
39    /// Group type.
40    pub kind: GroupKind,
41    /// Node indices (in the LayoutGraph) belonging to this group.
42    pub members: Vec<usize>,
43    /// The "primary" active device node index (if any).
44    pub primary_device: Option<usize>,
45}
46
47/// Detect all functional groups in the layout graph.
48pub fn detect_groups(graph: &LayoutGraph) -> Vec<FunctionalGroup> {
49    let mut assigned: BTreeSet<usize> = BTreeSet::new();
50    let mut groups = Vec::new();
51
52    // Mark anchor nodes as assigned (they don't belong to groups)
53    assigned.insert(graph.in_node);
54    assigned.insert(graph.out_node);
55    assigned.insert(graph.vcc_node);
56    assigned.insert(graph.gnd_node);
57
58    // Detect gain stages (triodes, BJTs, JFETs with surrounding passives)
59    let gain_stages = detect_gain_stages(graph, &assigned);
60    for gs in &gain_stages {
61        for &m in &gs.members {
62            assigned.insert(m);
63        }
64    }
65    groups.extend(gain_stages);
66
67    // Detect op-amp stages
68    let opamp_stages = detect_opamp_stages(graph, &assigned);
69    for os in &opamp_stages {
70        for &m in &os.members {
71            assigned.insert(m);
72        }
73    }
74    groups.extend(opamp_stages);
75
76    // Detect push-pull output sections
77    let pp = detect_push_pull(graph, &assigned);
78    for g in &pp {
79        for &m in &g.members {
80            assigned.insert(m);
81        }
82    }
83    groups.extend(pp);
84
85    // Detect tone stacks (clusters of pots + passives with no active devices)
86    let tone_stacks = detect_tone_stacks(graph, &assigned);
87    for ts in &tone_stacks {
88        for &m in &ts.members {
89            assigned.insert(m);
90        }
91    }
92    groups.extend(tone_stacks);
93
94    // Assign remaining components to input/output/generic groups
95    let remaining = assign_remaining(graph, &assigned);
96    groups.extend(remaining);
97
98    groups
99}
100
101// ---------------------------------------------------------------------------
102// Gain stage detection
103// ---------------------------------------------------------------------------
104
105fn detect_gain_stages(graph: &LayoutGraph, assigned: &BTreeSet<usize>) -> Vec<FunctionalGroup> {
106    let mut groups = Vec::new();
107    let mut stage_num = 0;
108    let mut locally_assigned: BTreeSet<usize> = BTreeSet::new();
109
110    for node in &graph.nodes {
111        if assigned.contains(&node.id) || locally_assigned.contains(&node.id) {
112            continue;
113        }
114
115        let is_gain_device = node
116            .comp
117            .as_ref()
118            .map_or(false, |c| c.kind.is_gain_device());
119
120        if !is_gain_device {
121            continue;
122        }
123
124        stage_num += 1;
125        let mut members = vec![node.id];
126
127        // Walk neighbors to find associated passives:
128        // - Plate/collector load resistors (connect to vcc)
129        // - Cathode/emitter bias R+C (connect to gnd)
130        // - Input coupling cap
131        // - Grid/base leak resistor
132        let neighbors = graph.neighbors(node.id);
133        for &nid in &neighbors {
134            if assigned.contains(&nid) || locally_assigned.contains(&nid) || nid == node.id {
135                continue;
136            }
137            if !graph.is_passive(nid) {
138                continue;
139            }
140
141            // Check if this passive connects to vcc or gnd (supply/bias component)
142            if graph.connects_to_vcc(nid) || graph.connects_to_gnd(nid) {
143                members.push(nid);
144                continue;
145            }
146
147            // Check if this passive connects to only this active device and one other
148            // (coupling cap or grid leak)
149            let passive_neighbors = graph.neighbors(nid);
150            if passive_neighbors.len() <= 5 {
151                // Small connectivity — likely part of this stage
152                let connects_to_other_active = passive_neighbors.iter().any(|&pid| {
153                    pid != node.id
154                        && !assigned.contains(&pid)
155                        && !locally_assigned.contains(&pid)
156                        && graph.is_active_device(pid)
157                });
158                if !connects_to_other_active {
159                    members.push(nid);
160                }
161            }
162        }
163
164        for &m in &members {
165            locally_assigned.insert(m);
166        }
167
168        let device_name = &node.comp_id;
169        let kind_name = node
170            .comp
171            .as_ref()
172            .map_or("Gain", |c| match c.kind.layout_class() {
173                "triode" | "vari_mu" => "Triode",
174                "pentode" => "Pentode",
175                "npn" | "pnp" => "BJT",
176                "njfet" | "pjfet" => "JFET",
177                "nmos" | "pmos" => "MOSFET",
178                _ => "Gain",
179            });
180
181        groups.push(FunctionalGroup {
182            name: format!("gain_stage_{stage_num}"),
183            label: format!("{device_name} {kind_name} Stage"),
184            kind: GroupKind::GainStage,
185            members,
186            primary_device: Some(node.id),
187        });
188    }
189
190    groups
191}
192
193// ---------------------------------------------------------------------------
194// Op-amp stage detection
195// ---------------------------------------------------------------------------
196
197fn detect_opamp_stages(graph: &LayoutGraph, assigned: &BTreeSet<usize>) -> Vec<FunctionalGroup> {
198    let mut groups = Vec::new();
199    let mut stage_num = 0;
200    let mut locally_assigned: BTreeSet<usize> = BTreeSet::new();
201
202    for node in &graph.nodes {
203        if assigned.contains(&node.id) || locally_assigned.contains(&node.id) {
204            continue;
205        }
206
207        let is_opamp = node
208            .comp
209            .as_ref()
210            .map_or(false, |c| c.kind.op_amp_type().is_some());
211
212        if !is_opamp {
213            continue;
214        }
215
216        stage_num += 1;
217        let mut members = vec![node.id];
218
219        // Collect feedback network components (connect between neg input and output)
220        let neighbors = graph.neighbors(node.id);
221        for &nid in &neighbors {
222            if assigned.contains(&nid) || locally_assigned.contains(&nid) || nid == node.id {
223                continue;
224            }
225            // Include passives and diodes in the feedback network
226            let is_feedback_component = graph
227                .node_kind(nid)
228                .map_or(false, |k| k.is_simple_passive() || k.is_diode_family());
229            if is_feedback_component {
230                members.push(nid);
231            }
232        }
233
234        for &m in &members {
235            locally_assigned.insert(m);
236        }
237
238        let device_name = &node.comp_id;
239        groups.push(FunctionalGroup {
240            name: format!("opamp_stage_{stage_num}"),
241            label: format!("{device_name} Op-Amp Stage"),
242            kind: GroupKind::OpAmpStage,
243            members,
244            primary_device: Some(node.id),
245        });
246    }
247
248    groups
249}
250
251// ---------------------------------------------------------------------------
252// Push-pull output detection
253// ---------------------------------------------------------------------------
254
255fn detect_push_pull(graph: &LayoutGraph, assigned: &BTreeSet<usize>) -> Vec<FunctionalGroup> {
256    let mut groups = Vec::new();
257
258    // Find transformers — push-pull outputs always have one
259    let transformers: Vec<usize> = graph
260        .nodes
261        .iter()
262        .filter(|n| {
263            !assigned.contains(&n.id) && n.comp.as_ref().map_or(false, |c| c.kind.is_transformer())
264        })
265        .map(|n| n.id)
266        .collect();
267
268    for tx_id in transformers {
269        let mut members = vec![tx_id];
270
271        // Find pentodes connected to the transformer
272        let neighbors = graph.neighbors(tx_id);
273        let pentodes: Vec<usize> = neighbors
274            .iter()
275            .filter(|&&nid| {
276                !assigned.contains(&nid)
277                    && graph
278                        .node_kind(nid)
279                        .map_or(false, |k| k.layout_class() == "pentode")
280            })
281            .copied()
282            .collect();
283
284        if pentodes.len() >= 2 {
285            // This is a push-pull output
286            for &pid in &pentodes {
287                members.push(pid);
288                // Also grab bias components connected to each pentode
289                for &nid in &graph.neighbors(pid) {
290                    if !assigned.contains(&nid) && graph.is_passive(nid) {
291                        if graph.connects_to_vcc(nid) || graph.connects_to_gnd(nid) {
292                            members.push(nid);
293                        }
294                    }
295                }
296            }
297
298            groups.push(FunctionalGroup {
299                name: "push_pull_output".into(),
300                label: "Push-Pull Output".into(),
301                kind: GroupKind::PushPullOutput,
302                members,
303                primary_device: Some(tx_id),
304            });
305        }
306    }
307
308    groups
309}
310
311// ---------------------------------------------------------------------------
312// Tone stack detection
313// ---------------------------------------------------------------------------
314
315fn detect_tone_stacks(graph: &LayoutGraph, assigned: &BTreeSet<usize>) -> Vec<FunctionalGroup> {
316    let mut groups = Vec::new();
317
318    // Find pots that aren't already assigned
319    let unassigned_pots: Vec<usize> = graph
320        .nodes
321        .iter()
322        .filter(|n| !assigned.contains(&n.id) && n.comp.as_ref().map_or(false, |c| c.kind.is_pot()))
323        .map(|n| n.id)
324        .collect();
325
326    if unassigned_pots.is_empty() {
327        return groups;
328    }
329
330    // Group pots that are connected to each other (or to shared passives)
331    let mut pot_clusters: Vec<BTreeSet<usize>> = Vec::new();
332
333    for &pot_id in &unassigned_pots {
334        // Find which existing cluster this pot belongs to (via shared neighbors)
335        let pot_neighbors = graph.neighbors(pot_id);
336        let mut matching_cluster = None;
337
338        for (ci, cluster) in pot_clusters.iter().enumerate() {
339            // Check if this pot shares a neighbor with any pot in the cluster
340            for &existing_pot in cluster.iter() {
341                let existing_neighbors = graph.neighbors(existing_pot);
342                if pot_neighbors
343                    .intersection(&existing_neighbors)
344                    .next()
345                    .is_some()
346                {
347                    matching_cluster = Some(ci);
348                    break;
349                }
350            }
351            if matching_cluster.is_some() {
352                break;
353            }
354        }
355
356        match matching_cluster {
357            Some(ci) => {
358                pot_clusters[ci].insert(pot_id);
359            }
360            None => {
361                let mut cluster = BTreeSet::new();
362                cluster.insert(pot_id);
363                pot_clusters.push(cluster);
364            }
365        }
366    }
367
368    let mut ts_num = 0;
369    for cluster in pot_clusters {
370        ts_num += 1;
371        let mut members: Vec<usize> = cluster.iter().copied().collect();
372
373        // Add passives that connect between the pots
374        for &pot_id in &cluster {
375            for &nid in &graph.neighbors(pot_id) {
376                if assigned.contains(&nid) || members.contains(&nid) {
377                    continue;
378                }
379                if graph.is_passive(nid) && !graph.is_active_device(nid) {
380                    members.push(nid);
381                }
382            }
383        }
384
385        let pot_names: Vec<&str> = members
386            .iter()
387            .filter_map(|&m| {
388                if graph.node_kind(m).map_or(false, |k| k.is_pot()) {
389                    Some(graph.nodes[m].comp_id.as_str())
390                } else {
391                    None
392                }
393            })
394            .collect();
395
396        let label = if pot_names.len() == 1 {
397            format!("{} Control", pot_names[0])
398        } else {
399            "Tone Stack".into()
400        };
401
402        groups.push(FunctionalGroup {
403            name: format!("tone_stack_{ts_num}"),
404            label,
405            kind: GroupKind::ToneStack,
406            members,
407            primary_device: None,
408        });
409    }
410
411    groups
412}
413
414// ---------------------------------------------------------------------------
415// Remaining components
416// ---------------------------------------------------------------------------
417
418fn assign_remaining(graph: &LayoutGraph, assigned: &BTreeSet<usize>) -> Vec<FunctionalGroup> {
419    let mut groups = Vec::new();
420    let mut input_members = Vec::new();
421    let mut output_members = Vec::new();
422    let mut generic_members = Vec::new();
423
424    for node in &graph.nodes {
425        if assigned.contains(&node.id) || node.is_anchor {
426            continue;
427        }
428
429        // Check if this component connects to input or output
430        let connects_to_in = graph.edges.iter().any(|e| {
431            (e.from == graph.in_node && e.to == node.id)
432                || (e.from == node.id && e.to == graph.in_node)
433        });
434        let connects_to_out = graph.edges.iter().any(|e| {
435            (e.from == graph.out_node && e.to == node.id)
436                || (e.from == node.id && e.to == graph.out_node)
437        });
438
439        if connects_to_in {
440            input_members.push(node.id);
441        } else if connects_to_out {
442            output_members.push(node.id);
443        } else {
444            generic_members.push(node.id);
445        }
446    }
447
448    if !input_members.is_empty() {
449        groups.push(FunctionalGroup {
450            name: "input".into(),
451            label: "Input".into(),
452            kind: GroupKind::InputSection,
453            members: input_members,
454            primary_device: None,
455        });
456    }
457
458    if !output_members.is_empty() {
459        groups.push(FunctionalGroup {
460            name: "output".into(),
461            label: "Output".into(),
462            kind: GroupKind::OutputSection,
463            members: output_members,
464            primary_device: None,
465        });
466    }
467
468    if !generic_members.is_empty() {
469        groups.push(FunctionalGroup {
470            name: "misc".into(),
471            label: "Misc".into(),
472            kind: GroupKind::Generic,
473            members: generic_members,
474            primary_device: None,
475        });
476    }
477
478    groups
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use pedalkernel::compiler::components::*;
485    use pedalkernel::dsl::*;
486
487    #[test]
488    fn detect_triode_gain_stage() {
489        let pedal = PedalDef {
490            name: "Test".into(),
491            supplies: vec![],
492            components: vec![
493                ComponentDef {
494                    id: "C1".into(),
495                    kind: Box::new(Capacitor {
496                        config: CapConfig::new(100e-9),
497                    }),
498                },
499                ComponentDef {
500                    id: "R1".into(),
501                    kind: Box::new(Resistor { value: 1e6 }),
502                },
503                ComponentDef {
504                    id: "V1".into(),
505                    kind: Box::new(Triode {
506                        model: "12AX7".into(),
507                    }),
508                },
509                ComponentDef {
510                    id: "R2".into(),
511                    kind: Box::new(Resistor { value: 100e3 }),
512                },
513                ComponentDef {
514                    id: "R3".into(),
515                    kind: Box::new(Resistor { value: 1500.0 }),
516                },
517                ComponentDef {
518                    id: "C2".into(),
519                    kind: Box::new(Capacitor {
520                        config: CapConfig::new(25e-6),
521                    }),
522                },
523            ],
524            nets: vec![
525                NetDef {
526                    from: Pin::Reserved("in".into()),
527                    to: vec![Pin::ComponentPin {
528                        component: "C1".into(),
529                        pin: "a".into(),
530                    }],
531                },
532                NetDef {
533                    from: Pin::ComponentPin {
534                        component: "C1".into(),
535                        pin: "b".into(),
536                    },
537                    to: vec![
538                        Pin::ComponentPin {
539                            component: "R1".into(),
540                            pin: "a".into(),
541                        },
542                        Pin::ComponentPin {
543                            component: "V1".into(),
544                            pin: "grid".into(),
545                        },
546                    ],
547                },
548                NetDef {
549                    from: Pin::Reserved("vcc".into()),
550                    to: vec![Pin::ComponentPin {
551                        component: "R2".into(),
552                        pin: "a".into(),
553                    }],
554                },
555                NetDef {
556                    from: Pin::ComponentPin {
557                        component: "R2".into(),
558                        pin: "b".into(),
559                    },
560                    to: vec![Pin::ComponentPin {
561                        component: "V1".into(),
562                        pin: "plate".into(),
563                    }],
564                },
565                NetDef {
566                    from: Pin::ComponentPin {
567                        component: "V1".into(),
568                        pin: "cathode".into(),
569                    },
570                    to: vec![
571                        Pin::ComponentPin {
572                            component: "R3".into(),
573                            pin: "a".into(),
574                        },
575                        Pin::ComponentPin {
576                            component: "C2".into(),
577                            pin: "a".into(),
578                        },
579                    ],
580                },
581                NetDef {
582                    from: Pin::ComponentPin {
583                        component: "R3".into(),
584                        pin: "b".into(),
585                    },
586                    to: vec![Pin::Reserved("gnd".into())],
587                },
588                NetDef {
589                    from: Pin::ComponentPin {
590                        component: "C2".into(),
591                        pin: "b".into(),
592                    },
593                    to: vec![Pin::Reserved("gnd".into())],
594                },
595                NetDef {
596                    from: Pin::ComponentPin {
597                        component: "R1".into(),
598                        pin: "b".into(),
599                    },
600                    to: vec![Pin::Reserved("gnd".into())],
601                },
602                NetDef {
603                    from: Pin::ComponentPin {
604                        component: "V1".into(),
605                        pin: "plate".into(),
606                    },
607                    to: vec![Pin::Reserved("out".into())],
608                },
609            ],
610            controls: vec![],
611            trims: vec![],
612            monitors: vec![],
613            sidechains: vec![],
614            mirrors: Default::default(),
615            subtitle: None,
616            calibrate: false,
617            subcircuits: vec![],
618            ports: vec![],
619            init_hints: vec![],
620            uses: vec![],
621        };
622
623        let graph = LayoutGraph::from_pedal(&pedal);
624        let groups = detect_groups(&graph);
625
626        // Should detect at least one gain stage
627        let gain_stages: Vec<_> = groups
628            .iter()
629            .filter(|g| g.kind == GroupKind::GainStage)
630            .collect();
631        assert!(!gain_stages.is_empty(), "Should detect a gain stage");
632
633        // The gain stage should contain the triode
634        let gs = &gain_stages[0];
635        let has_triode = gs.members.iter().any(|&m| graph.nodes[m].comp_id == "V1");
636        assert!(has_triode, "Gain stage should contain the triode");
637    }
638}