netlistmod 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. netlistmod/__init__.py +211 -0
  2. netlistmod/dialects.py +25 -0
  3. netlistmod/document/__init__.py +36 -0
  4. netlistmod/document/diagnostics.py +474 -0
  5. netlistmod/document/document.py +255 -0
  6. netlistmod/document/index.py +523 -0
  7. netlistmod/document/lines.py +119 -0
  8. netlistmod/document/model-types.json +18 -0
  9. netlistmod/document/models.py +43 -0
  10. netlistmod/document/names.py +51 -0
  11. netlistmod/document/topology.py +274 -0
  12. netlistmod/edit/__init__.py +17 -0
  13. netlistmod/edit/_ops_behavioral.py +62 -0
  14. netlistmod/edit/_ops_common.py +223 -0
  15. netlistmod/edit/_ops_controlled.py +59 -0
  16. netlistmod/edit/_ops_directives.py +246 -0
  17. netlistmod/edit/_ops_elements.py +281 -0
  18. netlistmod/edit/_ops_sources.py +244 -0
  19. netlistmod/edit/_ops_statements.py +84 -0
  20. netlistmod/edit/api.py +685 -0
  21. netlistmod/edit/conventions.py +64 -0
  22. netlistmod/edit/operations.py +77 -0
  23. netlistmod/edit/placement.py +133 -0
  24. netlistmod/edit/plan.py +122 -0
  25. netlistmod/edit/verify.py +184 -0
  26. netlistmod/errors.py +68 -0
  27. netlistmod/grammar/__init__.py +5 -0
  28. netlistmod/grammar/core.lark +547 -0
  29. netlistmod/grammar/formula-reserved.json +18 -0
  30. netlistmod/grammar/formula.lark +77 -0
  31. netlistmod/grammar/formula.py +113 -0
  32. netlistmod/grammar/formula_ltspice.lark +21 -0
  33. netlistmod/grammar/loader.py +461 -0
  34. netlistmod/grammar/ltspice-devices.json +27 -0
  35. netlistmod/grammar/ltspice.lark +649 -0
  36. netlistmod/grammar/ngspice-devices.json +28 -0
  37. netlistmod/grammar/ngspice.lark +571 -0
  38. netlistmod/model/__init__.py +145 -0
  39. netlistmod/model/_rules_base.py +109 -0
  40. netlistmod/model/_rules_behavioral.py +70 -0
  41. netlistmod/model/_rules_controlled.py +185 -0
  42. netlistmod/model/_rules_elements.py +412 -0
  43. netlistmod/model/_rules_sources.py +268 -0
  44. netlistmod/model/_rules_statements.py +559 -0
  45. netlistmod/model/_rules_structure.py +136 -0
  46. netlistmod/model/_tree.py +358 -0
  47. netlistmod/model/nodes.py +731 -0
  48. netlistmod/model/transform.py +95 -0
  49. netlistmod/model/values.py +131 -0
  50. netlistmod/py.typed +0 -0
  51. netlistmod/source.py +289 -0
  52. netlistmod-0.5.0.dist-info/METADATA +94 -0
  53. netlistmod-0.5.0.dist-info/RECORD +55 -0
  54. netlistmod-0.5.0.dist-info/WHEEL +4 -0
  55. netlistmod-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,474 @@
1
+ """Non-fatal findings about a netlist, delivered as data (architecture §8, §17).
2
+
3
+ Diagnostics are computed once when a :class:`~netlistmod.document.Netlist` is built and are
4
+ never raised. Their codes and severities are fixed by decision 0006.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+ from typing import Literal
13
+
14
+ from netlistmod.dialects import Dialect
15
+ from netlistmod.document.index import Index, Reference, Scope, definition_scope, walk_nodes
16
+ from netlistmod.document.models import allowed_model_types, model_type_allowed
17
+ from netlistmod.document.names import instance_name, is_automatic_global, is_ground
18
+ from netlistmod.model.nodes import (
19
+ Cccs,
20
+ Ccvs,
21
+ Component,
22
+ CurrentSwitch,
23
+ GlobalDirective,
24
+ Inductor,
25
+ ModelStatement,
26
+ StepDirective,
27
+ SubcktDefinition,
28
+ SubcktInstance,
29
+ UnknownDirective,
30
+ Vcvs,
31
+ VoltageSource,
32
+ element_phrase,
33
+ )
34
+ from netlistmod.model.values import Formula, NumericValue
35
+ from netlistmod.source import SourceText, Span
36
+
37
+ Severity = Literal["warning", "info"]
38
+
39
+
40
+ class DiagnosticCode(StrEnum):
41
+ """The code of a diagnostic (architecture §17)."""
42
+
43
+ UNRESOLVED_MODEL = "UNRESOLVED_MODEL"
44
+ UNRESOLVED_SUBCKT = "UNRESOLVED_SUBCKT"
45
+ UNRESOLVED_PARAM = "UNRESOLVED_PARAM"
46
+ UNRESOLVED_ELEMENT = "UNRESOLVED_ELEMENT"
47
+ DUPLICATE_DEFINITION = "DUPLICATE_DEFINITION"
48
+ DUPLICATE_ELEMENT = "DUPLICATE_ELEMENT"
49
+ SUBCKT_PORT_MISMATCH = "SUBCKT_PORT_MISMATCH"
50
+ PARAM_USED_BEFORE_DEFINITION = "PARAM_USED_BEFORE_DEFINITION"
51
+ MODEL_TYPE_MISMATCH = "MODEL_TYPE_MISMATCH"
52
+ ENDS_NAME_MISMATCH = "ENDS_NAME_MISMATCH"
53
+ DIALECT_GATED_SYNTAX = "DIALECT_GATED_SYNTAX"
54
+ RKM_SOLVER_MISMATCH = "RKM_SOLVER_MISMATCH"
55
+ UNTYPED_DIRECTIVE_PRESERVED = "UNTYPED_DIRECTIVE_PRESERVED"
56
+ NODE_INTO_INSTANCE = "NODE_INTO_INSTANCE"
57
+ CONTROL_SOURCE_MISMATCH = "CONTROL_SOURCE_MISMATCH"
58
+ UNRESOLVED_NODE = "UNRESOLVED_NODE"
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class Diagnostic:
63
+ """One finding: its code, severity, a message, and where it is (1-based line and column)."""
64
+
65
+ code: DiagnosticCode
66
+ severity: Severity
67
+ message: str
68
+ span: Span
69
+ line: int
70
+ column: int
71
+
72
+
73
+ _UNRESOLVED: dict[str, tuple[DiagnosticCode, str]] = {
74
+ "model": (DiagnosticCode.UNRESOLVED_MODEL, "model"),
75
+ "subckt": (DiagnosticCode.UNRESOLVED_SUBCKT, "subcircuit"),
76
+ "param": (DiagnosticCode.UNRESOLVED_PARAM, "parameter"),
77
+ "element": (DiagnosticCode.UNRESOLVED_ELEMENT, "element"),
78
+ "node": (DiagnosticCode.UNRESOLVED_NODE, "node"),
79
+ }
80
+ _ORDER = {code: position for position, code in enumerate(DiagnosticCode)}
81
+ # The solver version whose behaviour a message states (validated_on of the rule tables).
82
+ _SOLVER: dict[Dialect, str] = {Dialect.NGSPICE: "ngspice 47", Dialect.LTSPICE: "LTspice 26.1.1"}
83
+
84
+
85
+ def collect_diagnostics(index: Index, source: SourceText) -> tuple[Diagnostic, ...]:
86
+ """Compute every diagnostic of a netlist, in source order (decision 0006 items 7 and 8)."""
87
+ found: list[tuple[DiagnosticCode, Severity, str, Span]] = []
88
+ for reference in index.references:
89
+ found.extend(_reference_findings(index, reference))
90
+ for scope in index.scopes:
91
+ found.extend(_duplicate_findings(scope))
92
+ if index.dialect is Dialect.LTSPICE:
93
+ found.extend(_instance_node_findings(index, scope))
94
+ for line in index.lines.walk():
95
+ item = line.item
96
+ if isinstance(item, SubcktDefinition) and item.ends.name is not None:
97
+ if index.root.fold(item.ends.name) != index.root.fold(item.name):
98
+ assert item.ends.name_span is not None
99
+ checks = (
100
+ "ngspice does not check the name (ngspice manual §2.6.2)"
101
+ if index.dialect is Dialect.NGSPICE
102
+ else f"{_SOLVER[index.dialect]} does not check the name"
103
+ )
104
+ found.append(
105
+ (
106
+ DiagnosticCode.ENDS_NAME_MISMATCH,
107
+ "info",
108
+ f"`.ends {item.ends.name}` closes subcircuit `{item.name}`; {checks}",
109
+ item.ends.name_span,
110
+ )
111
+ )
112
+ if isinstance(item, UnknownDirective):
113
+ span = item.name_span or item.span
114
+ found.append(
115
+ (
116
+ DiagnosticCode.UNTYPED_DIRECTIVE_PRESERVED,
117
+ "info",
118
+ f"`{item.name}` is not typed by the library and is preserved verbatim",
119
+ span,
120
+ )
121
+ )
122
+ # Bodies are walked as their own lines, so a definition contributes only its header.
123
+ for value in walk_nodes(item, NumericValue, bodies=False):
124
+ if index.dialect is Dialect.NGSPICE:
125
+ found.extend(_value_findings(value))
126
+ found.sort(key=lambda entry: (entry[3].start, entry[3].end, _ORDER[entry[0]]))
127
+ diagnostics = []
128
+ for code, severity, message, span in found:
129
+ line_number, column = source.position(span.start)
130
+ diagnostics.append(Diagnostic(code, severity, message, span, line_number, column))
131
+ return tuple(diagnostics)
132
+
133
+
134
+ def _reference_findings(
135
+ index: Index, reference: Reference
136
+ ) -> list[tuple[DiagnosticCode, Severity, str, Span]]:
137
+ findings: list[tuple[DiagnosticCode, Severity, str, Span]] = []
138
+ scope = index.scope(reference.scope)
139
+ if reference.target is None and reference.kind in _UNRESOLVED:
140
+ code, noun = _UNRESOLVED[reference.kind]
141
+ searched = (scope,) if reference.kind in ("element", "node") else tuple(scope.chain())
142
+ where = ", ".join(candidate.describe() for candidate in searched)
143
+ message = f"{noun} `{reference.name}` is not defined (searched: {where})"
144
+ severity: Severity = "warning"
145
+ into = _into_instance(index, scope, reference)
146
+ if into is not None:
147
+ severity = "info"
148
+ what = "a node" if reference.kind == "node" else "a source"
149
+ message += (
150
+ f"; it names {what} inside instance `{into}`, which {_SOLVER[index.dialect]} "
151
+ "reads and the library does not resolve"
152
+ )
153
+ elif any(candidate.may_define_elsewhere for candidate in searched):
154
+ severity = "info"
155
+ message += (
156
+ "; it may be defined in an `.include` or `.lib` file or an opaque block, which "
157
+ "is not read"
158
+ )
159
+ findings.append((code, severity, message, reference.span))
160
+ if reference in index.late_param_uses:
161
+ findings.append(
162
+ (
163
+ DiagnosticCode.PARAM_USED_BEFORE_DEFINITION,
164
+ "warning",
165
+ f"parameter `{reference.name}` is used before its `.param` definition"
166
+ f"{' (ngspice manual §2.11.1)' if index.dialect is Dialect.NGSPICE else ''}; "
167
+ f"{_SOLVER[index.dialect]} still resolves it",
168
+ reference.span,
169
+ )
170
+ )
171
+ owner = reference.owner
172
+ target = reference.target
173
+ if (
174
+ reference.kind == "model"
175
+ and isinstance(target, ModelStatement)
176
+ and isinstance(owner, Component)
177
+ and not model_type_allowed(owner, target.model_type, index.dialect)
178
+ ):
179
+ allowed = ", ".join(
180
+ f"`{kind.upper()}`" for kind in sorted(allowed_model_types(owner, index.dialect))
181
+ )
182
+ cited = " (ngspice manual §2.5)" if index.dialect is Dialect.NGSPICE else ""
183
+ findings.append(
184
+ (
185
+ DiagnosticCode.MODEL_TYPE_MISMATCH,
186
+ "warning",
187
+ f"`{owner.designator}` refers to model `{target.name}` of type "
188
+ f"`{target.model_type}`, but {element_phrase(owner)} takes a model of "
189
+ f"type {allowed}{cited}",
190
+ reference.span,
191
+ )
192
+ )
193
+ if (
194
+ reference.kind == "element"
195
+ and isinstance(owner, Cccs | Ccvs | CurrentSwitch)
196
+ and owner.control is not None
197
+ and reference.span == owner.control.span
198
+ and target is not None
199
+ and isinstance(target, Component)
200
+ and not isinstance(target, VoltageSource)
201
+ ):
202
+ findings.append(
203
+ (
204
+ DiagnosticCode.CONTROL_SOURCE_MISMATCH,
205
+ "warning",
206
+ f"`{owner.designator}` is controlled by `{reference.name}`, "
207
+ f"{element_phrase(target)}, but the {_reference_for(owner, index.dialect)} "
208
+ f"names an independent voltage source (`V`); "
209
+ f"{_control_outcome(target, reference.name, index.dialect)}",
210
+ reference.span,
211
+ )
212
+ )
213
+ if (
214
+ reference.kind == "subckt"
215
+ and isinstance(reference.target, SubcktDefinition)
216
+ and isinstance(owner, SubcktInstance)
217
+ and len(owner.nodes) != len(reference.target.ports)
218
+ ):
219
+ findings.append(
220
+ (
221
+ DiagnosticCode.SUBCKT_PORT_MISMATCH,
222
+ "warning",
223
+ f"`{owner.designator}` connects {len(owner.nodes)} nodes, but subcircuit "
224
+ f"`{reference.target.name}` has {len(reference.target.ports)} ports",
225
+ reference.span,
226
+ )
227
+ )
228
+ return findings
229
+
230
+
231
+ # What each solver does with a controlling source that is not a V (decision 0015 item 5).
232
+ _MANUAL_SECTION: dict[type, str] = {Cccs: "4.2.3", Ccvs: "4.2.4", CurrentSwitch: "3.3.15"}
233
+
234
+
235
+ def _reference_for(owner: Component, dialect: Dialect) -> str:
236
+ if dialect is Dialect.LTSPICE:
237
+ return "LTspice reference"
238
+ return f"ngspice manual §{_MANUAL_SECTION[type(owner)]}"
239
+
240
+
241
+ _CONTROL_RUNS: dict[Dialect, tuple[type, ...]] = {
242
+ Dialect.NGSPICE: (Vcvs, Ccvs),
243
+ Dialect.LTSPICE: (Vcvs, Ccvs, Inductor),
244
+ }
245
+
246
+
247
+ def _control_outcome(target: Component, name: str, dialect: Dialect) -> str:
248
+ """What the dialect's solver does with this controlling source (review R2)."""
249
+ if isinstance(target, _CONTROL_RUNS[dialect]):
250
+ return f"{_SOLVER[dialect]} runs it, with the current through `{name}`"
251
+ return f"{_SOLVER[dialect]} stops on it"
252
+
253
+
254
+ def _into_instance(index: Index, scope: Scope, reference: Reference) -> str | None:
255
+ """The instance of the scope that an unresolved name reaches into, if it names one.
256
+
257
+ Both solvers read such a name and the library keeps it as text, a known limit (§7.8;
258
+ decision 0016 item 6):
259
+
260
+ - LTspice: ``X1:Vq`` for any element reference (a controlling source, an ``I()`` read, a
261
+ ``K`` inductor, a ``.save I(...)``), and ``X1:n`` for a node an expression reads;
262
+ - ngspice: ``x1.n`` for a node an expression reads, and ``v.x1.vq`` (the device letter, the
263
+ instance, the name) for a source it reads. ngspice stops on ``i(x1.vq)``, so that one
264
+ stays a warning.
265
+ """
266
+ if reference.kind not in ("element", "node"):
267
+ return None
268
+ if index.dialect is Dialect.LTSPICE:
269
+ head, colon, _ = reference.name.partition(":")
270
+ if not colon:
271
+ return None
272
+ elif reference.kind == "node":
273
+ head, dot, _ = reference.name.partition(".")
274
+ if not dot:
275
+ return None
276
+ else:
277
+ parts = reference.name.split(".")
278
+ if len(parts) < 3 or len(parts[0]) != 1 or reference.owner is None:
279
+ return None
280
+ if not _is_expression_read(reference):
281
+ return None
282
+ head = parts[1]
283
+ instance = next(iter(scope.elements.get(scope.fold(head), ())), None)
284
+ return head if isinstance(instance, SubcktInstance) else None
285
+
286
+
287
+ def _is_expression_read(reference: Reference) -> bool:
288
+ """Whether an element reference is an ``i()`` read in a behavioural expression."""
289
+ owner = reference.owner
290
+ formulas = [
291
+ getattr(owner, "expression", None),
292
+ getattr(owner, "charge", None),
293
+ getattr(owner, "flux", None),
294
+ getattr(owner, "value", None),
295
+ ]
296
+ return any(
297
+ isinstance(formula, Formula)
298
+ and formula.span.start <= reference.span.start < formula.span.end
299
+ for formula in formulas
300
+ )
301
+
302
+
303
+ def _instance_node_findings(
304
+ index: Index, scope: Scope
305
+ ) -> list[tuple[DiagnosticCode, Severity, str, Span]]:
306
+ """LTspice nodes named ``<instance>:<node>``, which the library keeps as nets of their own."""
307
+ findings: list[tuple[DiagnosticCode, Severity, str, Span]] = []
308
+ solver = _SOLVER[scope.dialect]
309
+ for elements in scope.elements.values():
310
+ for element in elements:
311
+ for node in element.nodes:
312
+ reading = _reading_of_instance_node(index, scope, node.name)
313
+ if reading == "joined":
314
+ severity: Severity = "warning"
315
+ rule = f"{solver} connects it to that node inside the instance"
316
+ elif reading == "unknown":
317
+ severity = "info"
318
+ rule = f"{solver} connects it to a node of that name in the included definition"
319
+ elif reading == "stopped":
320
+ severity = "warning"
321
+ rule = f"{solver} stops: the part after the instance is not a local node"
322
+ else:
323
+ continue
324
+ findings.append(
325
+ (
326
+ DiagnosticCode.NODE_INTO_INSTANCE,
327
+ severity,
328
+ f"node `{node.name}` names an instance; {rule}, and the library keeps it "
329
+ "as a separate net",
330
+ node.span,
331
+ )
332
+ )
333
+ return findings
334
+
335
+
336
+ def _reading_of_instance_node(
337
+ index: Index, scope: Scope, name: str
338
+ ) -> Literal["joined", "unknown", "stopped"] | None:
339
+ """How LTspice reads ``<instance>:<node>`` from ``scope`` (``names.md`` N11), if it is one."""
340
+ head, colon, rest = name.partition(":")
341
+ if not colon:
342
+ return None
343
+ key = scope.fold(head)
344
+ instance = next(
345
+ (
346
+ element
347
+ for elements in scope.elements.values()
348
+ for element in elements
349
+ if isinstance(element, SubcktInstance)
350
+ and instance_name(element.designator, scope.dialect) == key
351
+ ),
352
+ None,
353
+ )
354
+ if instance is None:
355
+ return None
356
+ child = definition_scope(scope, instance.subckt.name)
357
+ if ":" not in rest and (not rest or _is_global(index, rest)):
358
+ return "stopped"
359
+ if child is None:
360
+ included = any(candidate.may_define_elsewhere for candidate in scope.chain())
361
+ return "unknown" if included else None
362
+ if ":" in rest:
363
+ return _reading_of_instance_node(index, child, rest)
364
+ assert child.definition is not None
365
+ local = {child.fold(port.name) for port in child.definition.ports}
366
+ local.update(
367
+ child.fold(node.name)
368
+ for elements in child.elements.values()
369
+ for element in elements
370
+ for node in element.nodes
371
+ )
372
+ return "joined" if child.fold(rest) in local else None
373
+
374
+
375
+ def _is_global(index: Index, name: str) -> bool:
376
+ """Whether a node name means the same net everywhere: ground or a global node."""
377
+ if is_ground(name, index.dialect) or is_automatic_global(name, index.dialect):
378
+ return True
379
+ return any(
380
+ isinstance(line.item, GlobalDirective)
381
+ and any(index.root.fold(node.name) == index.root.fold(name) for node in line.item.nodes)
382
+ for line in index.lines
383
+ )
384
+
385
+
386
+ def _duplicate_findings(scope: Scope) -> list[tuple[DiagnosticCode, Severity, str, Span]]:
387
+ findings: list[tuple[DiagnosticCode, Severity, str, Span]] = []
388
+ where = scope.describe()
389
+ fatal = scope.dialect is Dialect.LTSPICE
390
+ tables: tuple[tuple[Mapping[str, tuple[object, ...]], str, Severity], ...] = (
391
+ # LTspice stops at a repeated .param; ngspice uses the last one silently.
392
+ (scope.params, "parameter", "warning" if fatal else "info"),
393
+ (scope.models, "model", "warning"),
394
+ (scope.subckts, "subcircuit", "warning"),
395
+ )
396
+ for table, noun, severity in tables:
397
+ for definitions in table.values():
398
+ for repeated in definitions[1:]:
399
+ name, span = _name_of(repeated)
400
+ if fatal:
401
+ rule = f"{_SOLVER[scope.dialect]} stops with an error"
402
+ if noun == "model":
403
+ rule += " if an element uses it and the definitions differ"
404
+ elif noun == "subcircuit":
405
+ rule += " if an instance uses it"
406
+ elif noun == "parameter":
407
+ rule = "the last assignment is used"
408
+ else:
409
+ rule = "ngspice uses the first definition"
410
+ findings.append(
411
+ (
412
+ DiagnosticCode.DUPLICATE_DEFINITION,
413
+ severity,
414
+ f"{noun} `{name}` is defined again in {where}; {rule}",
415
+ span,
416
+ )
417
+ )
418
+ seen: set[tuple[str, str]] = set()
419
+ for line in scope.lines:
420
+ if isinstance(line.item, StepDirective):
421
+ quantity = (line.item.kind, scope.fold(line.item.name))
422
+ if quantity in seen:
423
+ findings.append(
424
+ (
425
+ DiagnosticCode.DUPLICATE_DEFINITION,
426
+ "warning",
427
+ f"`.step` sweeps `{line.item.name}` again; {_SOLVER[scope.dialect]} stops "
428
+ "with an error",
429
+ line.item.name_span,
430
+ )
431
+ )
432
+ seen.add(quantity)
433
+ for elements in scope.elements.values():
434
+ for repeated in elements[1:]:
435
+ findings.append(
436
+ (
437
+ DiagnosticCode.DUPLICATE_ELEMENT,
438
+ "warning",
439
+ f"element `{repeated.designator}` repeats a designator in {where} "
440
+ f"(case-insensitive); {_SOLVER[scope.dialect]} stops with an error",
441
+ repeated.designator_span,
442
+ )
443
+ )
444
+ return findings
445
+
446
+
447
+ def _name_of(definition: object) -> tuple[str, Span]:
448
+ name: str = getattr(definition, "name")
449
+ span: Span = getattr(definition, "name_span")
450
+ return name, span
451
+
452
+
453
+ def _value_findings(value: NumericValue) -> list[tuple[DiagnosticCode, Severity, str, Span]]:
454
+ findings: list[tuple[DiagnosticCode, Severity, str, Span]] = []
455
+ if value.notation == "rkm":
456
+ findings.append(
457
+ (
458
+ DiagnosticCode.DIALECT_GATED_SYNTAX,
459
+ "info",
460
+ f"`{value.text}` is RKM notation, which ngspice reads only in LT compatibility "
461
+ "mode",
462
+ value.span,
463
+ )
464
+ )
465
+ if value.solver_mismatch:
466
+ findings.append(
467
+ (
468
+ DiagnosticCode.RKM_SOLVER_MISMATCH,
469
+ "warning",
470
+ f"`{value.text}` is read per IEC 60062, but ngspice reads it differently",
471
+ value.span,
472
+ )
473
+ )
474
+ return findings