mfcli 0.2.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.
- mfcli/.env.example +72 -0
- mfcli/__init__.py +0 -0
- mfcli/agents/__init__.py +0 -0
- mfcli/agents/controller/__init__.py +0 -0
- mfcli/agents/controller/agent.py +19 -0
- mfcli/agents/controller/config.yaml +27 -0
- mfcli/agents/controller/tools.py +42 -0
- mfcli/agents/tools/general.py +118 -0
- mfcli/alembic/env.py +61 -0
- mfcli/alembic/script.py.mako +28 -0
- mfcli/alembic/versions/6ccc0c7c397c_added_fields_to_pdf_parts_model.py +39 -0
- mfcli/alembic/versions/769019ef4870_added_gemini_file_path_to_pdf_part_model.py +33 -0
- mfcli/alembic/versions/7a2e3a779fdc_added_functional_block_and_component_.py +54 -0
- mfcli/alembic/versions/7d5adb2a47a7_added_pdf_parts_model.py +41 -0
- mfcli/alembic/versions/7fcb7d6a5836_init.py +167 -0
- mfcli/alembic/versions/e0f2b5765c72_added_cascade_delete_for_models_that_.py +32 -0
- mfcli/alembic.ini +147 -0
- mfcli/cli/__init__.py +0 -0
- mfcli/cli/dependencies.py +59 -0
- mfcli/cli/main.py +192 -0
- mfcli/client/__init__.py +0 -0
- mfcli/client/chroma_db.py +184 -0
- mfcli/client/docling.py +44 -0
- mfcli/client/gemini.py +252 -0
- mfcli/client/llama_parse.py +38 -0
- mfcli/client/vector_db.py +93 -0
- mfcli/constants/__init__.py +0 -0
- mfcli/constants/base_enum.py +18 -0
- mfcli/constants/directory_names.py +1 -0
- mfcli/constants/file_types.py +189 -0
- mfcli/constants/gemini.py +1 -0
- mfcli/constants/openai.py +6 -0
- mfcli/constants/pipeline_run_status.py +3 -0
- mfcli/crud/__init__.py +0 -0
- mfcli/crud/file.py +42 -0
- mfcli/crud/functional_blocks.py +26 -0
- mfcli/crud/netlist.py +18 -0
- mfcli/crud/pipeline_run.py +17 -0
- mfcli/crud/project.py +99 -0
- mfcli/digikey/__init__.py +0 -0
- mfcli/digikey/digikey.py +105 -0
- mfcli/main.py +5 -0
- mfcli/mcp/__init__.py +0 -0
- mfcli/mcp/configs/cline_mcp_settings.json +11 -0
- mfcli/mcp/configs/mfcli.mcp.json +7 -0
- mfcli/mcp/mcp_instance.py +6 -0
- mfcli/mcp/server.py +37 -0
- mfcli/mcp/state_manager.py +51 -0
- mfcli/mcp/tools/__init__.py +0 -0
- mfcli/mcp/tools/query_knowledgebase.py +108 -0
- mfcli/models/__init__.py +10 -0
- mfcli/models/base.py +10 -0
- mfcli/models/bom.py +71 -0
- mfcli/models/datasheet.py +10 -0
- mfcli/models/debug_setup.py +64 -0
- mfcli/models/file.py +43 -0
- mfcli/models/file_docket.py +94 -0
- mfcli/models/file_metadata.py +19 -0
- mfcli/models/functional_blocks.py +94 -0
- mfcli/models/llm_response.py +5 -0
- mfcli/models/mcu.py +97 -0
- mfcli/models/mcu_errata.py +26 -0
- mfcli/models/netlist.py +59 -0
- mfcli/models/pdf_parts.py +25 -0
- mfcli/models/pipeline_run.py +34 -0
- mfcli/models/project.py +27 -0
- mfcli/models/project_metadata.py +15 -0
- mfcli/pipeline/__init__.py +0 -0
- mfcli/pipeline/analysis/__init__.py +0 -0
- mfcli/pipeline/analysis/bom_netlist_mapper.py +28 -0
- mfcli/pipeline/analysis/generators/__init__.py +0 -0
- mfcli/pipeline/analysis/generators/bom/__init__.py +0 -0
- mfcli/pipeline/analysis/generators/bom/bom.py +74 -0
- mfcli/pipeline/analysis/generators/debug_setup/__init__.py +0 -0
- mfcli/pipeline/analysis/generators/debug_setup/debug_setup.py +71 -0
- mfcli/pipeline/analysis/generators/debug_setup/instructions.py +150 -0
- mfcli/pipeline/analysis/generators/functional_blocks/__init__.py +0 -0
- mfcli/pipeline/analysis/generators/functional_blocks/functional_blocks.py +93 -0
- mfcli/pipeline/analysis/generators/functional_blocks/instructions.py +34 -0
- mfcli/pipeline/analysis/generators/functional_blocks/validator.py +94 -0
- mfcli/pipeline/analysis/generators/generator.py +258 -0
- mfcli/pipeline/analysis/generators/generator_base.py +18 -0
- mfcli/pipeline/analysis/generators/mcu/__init__.py +0 -0
- mfcli/pipeline/analysis/generators/mcu/instructions.py +156 -0
- mfcli/pipeline/analysis/generators/mcu/mcu.py +84 -0
- mfcli/pipeline/analysis/generators/mcu_errata/__init__.py +1 -0
- mfcli/pipeline/analysis/generators/mcu_errata/instructions.py +77 -0
- mfcli/pipeline/analysis/generators/mcu_errata/mcu_errata.py +95 -0
- mfcli/pipeline/analysis/generators/summary/__init__.py +0 -0
- mfcli/pipeline/analysis/generators/summary/summary.py +47 -0
- mfcli/pipeline/classifier.py +93 -0
- mfcli/pipeline/data_enricher.py +15 -0
- mfcli/pipeline/extractor.py +34 -0
- mfcli/pipeline/extractors/__init__.py +0 -0
- mfcli/pipeline/extractors/pdf.py +12 -0
- mfcli/pipeline/parser.py +120 -0
- mfcli/pipeline/parsers/__init__.py +0 -0
- mfcli/pipeline/parsers/netlist/__init__.py +0 -0
- mfcli/pipeline/parsers/netlist/edif.py +93 -0
- mfcli/pipeline/parsers/netlist/kicad_legacy_net.py +326 -0
- mfcli/pipeline/parsers/netlist/kicad_spice.py +135 -0
- mfcli/pipeline/parsers/netlist/pads.py +185 -0
- mfcli/pipeline/parsers/netlist/protel.py +166 -0
- mfcli/pipeline/parsers/netlist/protel_detector.py +29 -0
- mfcli/pipeline/pipeline.py +419 -0
- mfcli/pipeline/preprocessors/__init__.py +0 -0
- mfcli/pipeline/preprocessors/user_guide.py +127 -0
- mfcli/pipeline/run_context.py +32 -0
- mfcli/pipeline/schema_mapper.py +89 -0
- mfcli/pipeline/sub_classifier.py +115 -0
- mfcli/utils/__init__.py +0 -0
- mfcli/utils/config.py +33 -0
- mfcli/utils/configurator.py +324 -0
- mfcli/utils/data_cleaner.py +82 -0
- mfcli/utils/datasheet_vectorizer.py +281 -0
- mfcli/utils/directory_manager.py +96 -0
- mfcli/utils/file_upload.py +298 -0
- mfcli/utils/files.py +16 -0
- mfcli/utils/http_requests.py +54 -0
- mfcli/utils/kb_lister.py +89 -0
- mfcli/utils/kb_remover.py +173 -0
- mfcli/utils/logger.py +28 -0
- mfcli/utils/mcp_configurator.py +311 -0
- mfcli/utils/migrations.py +18 -0
- mfcli/utils/orm.py +43 -0
- mfcli/utils/pdf_splitter.py +63 -0
- mfcli/utils/query_service.py +22 -0
- mfcli/utils/system_check.py +306 -0
- mfcli/utils/tools.py +31 -0
- mfcli/utils/vectorizer.py +28 -0
- mfcli-0.2.0.dist-info/METADATA +841 -0
- mfcli-0.2.0.dist-info/RECORD +136 -0
- mfcli-0.2.0.dist-info/WHEEL +5 -0
- mfcli-0.2.0.dist-info/entry_points.txt +3 -0
- mfcli-0.2.0.dist-info/licenses/LICENSE +21 -0
- mfcli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KiCad Legacy Netlist Parser
|
|
3
|
+
|
|
4
|
+
Parses KiCad legacy netlist files (.net) in S-expression format.
|
|
5
|
+
Extracts:
|
|
6
|
+
- Reference designators (ref_des)
|
|
7
|
+
- Part numbers (from value or PARTNUMBER property)
|
|
8
|
+
- Pin connections from nets section
|
|
9
|
+
|
|
10
|
+
Format:
|
|
11
|
+
(export (version "E")
|
|
12
|
+
(components
|
|
13
|
+
(comp (ref "C1")
|
|
14
|
+
(value "PART_NUMBER")
|
|
15
|
+
(property (name "PARTNUMBER") (value "..."))))
|
|
16
|
+
(nets
|
|
17
|
+
(net (code "1") (name "GND")
|
|
18
|
+
(node (ref "C1") (pin "1")))))
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
from mfcli.models.netlist import Component, NetlistSchema, Pin
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ============================================================================
|
|
29
|
+
# S-Expression Parser
|
|
30
|
+
# ============================================================================
|
|
31
|
+
|
|
32
|
+
class SExpression:
|
|
33
|
+
"""Represents a parsed S-expression."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, value: Optional[str] = None, children: Optional[List['SExpression']] = None):
|
|
36
|
+
self.value = value # String value for atoms, None for lists
|
|
37
|
+
self.children = children or [] # Child expressions for lists
|
|
38
|
+
|
|
39
|
+
def is_list(self) -> bool:
|
|
40
|
+
"""Check if this is a list (not an atom)."""
|
|
41
|
+
return self.value is None
|
|
42
|
+
|
|
43
|
+
def find(self, key: str) -> Optional['SExpression']:
|
|
44
|
+
"""Find first child with matching value."""
|
|
45
|
+
for child in self.children:
|
|
46
|
+
if child.is_list() and child.children and child.children[0].value == key:
|
|
47
|
+
return child
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def find_all(self, key: str) -> List['SExpression']:
|
|
51
|
+
"""Find all children with matching value."""
|
|
52
|
+
results = []
|
|
53
|
+
for child in self.children:
|
|
54
|
+
if child.is_list() and child.children and child.children[0].value == key:
|
|
55
|
+
results.append(child)
|
|
56
|
+
return results
|
|
57
|
+
|
|
58
|
+
def get_value(self, index: int = 0) -> Optional[str]:
|
|
59
|
+
"""Get value at index from children."""
|
|
60
|
+
if index < len(self.children):
|
|
61
|
+
return self.children[index].value
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def parse(text: str) -> List['SExpression']:
|
|
66
|
+
"""Parse S-expression text into tree structure."""
|
|
67
|
+
tokens = SExpression._tokenize(text)
|
|
68
|
+
expressions, _ = SExpression._parse_tokens(tokens, 0)
|
|
69
|
+
return expressions
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _tokenize(text: str) -> List[str]:
|
|
73
|
+
"""Tokenize S-expression text."""
|
|
74
|
+
tokens = []
|
|
75
|
+
i = 0
|
|
76
|
+
while i < len(text):
|
|
77
|
+
char = text[i]
|
|
78
|
+
|
|
79
|
+
# Skip whitespace
|
|
80
|
+
if char.isspace():
|
|
81
|
+
i += 1
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Handle parentheses
|
|
85
|
+
if char in '()':
|
|
86
|
+
tokens.append(char)
|
|
87
|
+
i += 1
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
# Handle quoted strings
|
|
91
|
+
if char == '"':
|
|
92
|
+
j = i + 1
|
|
93
|
+
while j < len(text) and text[j] != '"':
|
|
94
|
+
if text[j] == '\\':
|
|
95
|
+
j += 2 # Skip escaped character
|
|
96
|
+
else:
|
|
97
|
+
j += 1
|
|
98
|
+
tokens.append(text[i:j + 1]) # Include quotes
|
|
99
|
+
i = j + 1
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Handle atoms (unquoted strings)
|
|
103
|
+
j = i
|
|
104
|
+
while j < len(text) and not text[j].isspace() and text[j] not in '()':
|
|
105
|
+
j += 1
|
|
106
|
+
tokens.append(text[i:j])
|
|
107
|
+
i = j
|
|
108
|
+
|
|
109
|
+
return tokens
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _parse_tokens(tokens: List[str], pos: int) -> Tuple[List['SExpression'], int]:
|
|
113
|
+
"""Parse tokens into S-expressions, returns (expressions, next_pos)."""
|
|
114
|
+
expressions = []
|
|
115
|
+
|
|
116
|
+
while pos < len(tokens):
|
|
117
|
+
token = tokens[pos]
|
|
118
|
+
|
|
119
|
+
if token == '(':
|
|
120
|
+
# Start of list
|
|
121
|
+
children, pos = SExpression._parse_tokens(tokens, pos + 1)
|
|
122
|
+
expressions.append(SExpression(children=children))
|
|
123
|
+
|
|
124
|
+
elif token == ')':
|
|
125
|
+
# End of list
|
|
126
|
+
return expressions, pos + 1
|
|
127
|
+
|
|
128
|
+
else:
|
|
129
|
+
# Atom - remove quotes if present
|
|
130
|
+
value = token.strip('"')
|
|
131
|
+
expressions.append(SExpression(value=value))
|
|
132
|
+
pos += 1
|
|
133
|
+
|
|
134
|
+
return expressions, pos
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ============================================================================
|
|
138
|
+
# KiCad Legacy Netlist Parser
|
|
139
|
+
# ============================================================================
|
|
140
|
+
|
|
141
|
+
class KiCadLegacyNetParser:
|
|
142
|
+
"""Parser for KiCad legacy netlist files."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, content: str):
|
|
145
|
+
self.content = content
|
|
146
|
+
self.root: Optional[SExpression] = None
|
|
147
|
+
self.components_map: Dict[str, str] = {} # ref_des -> part_number
|
|
148
|
+
self.nets_map: Dict[str, List[Tuple[str, str]]] = defaultdict(list) # net_name -> [(ref_des, pin), ...]
|
|
149
|
+
|
|
150
|
+
def parse(self) -> NetlistSchema:
|
|
151
|
+
"""Parse content and return validated schema."""
|
|
152
|
+
# Parse S-expressions
|
|
153
|
+
self.root = SExpression.parse(self.content)
|
|
154
|
+
|
|
155
|
+
if not self.root:
|
|
156
|
+
raise ValueError("Failed to parse S-expression")
|
|
157
|
+
|
|
158
|
+
# Find export section
|
|
159
|
+
export = None
|
|
160
|
+
for expr in self.root:
|
|
161
|
+
if expr.is_list() and expr.get_value(0) == "export":
|
|
162
|
+
export = expr
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
if not export:
|
|
166
|
+
raise ValueError("No (export ...) section found")
|
|
167
|
+
|
|
168
|
+
# Extract components and nets
|
|
169
|
+
self._extract_components(export)
|
|
170
|
+
self._extract_nets(export)
|
|
171
|
+
|
|
172
|
+
# Build schema
|
|
173
|
+
return self._build_schema()
|
|
174
|
+
|
|
175
|
+
def _extract_components(self, export: SExpression):
|
|
176
|
+
"""Extract components from (components ...) section."""
|
|
177
|
+
components_section = export.find("components")
|
|
178
|
+
if not components_section:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
# Find all (comp ...) entries
|
|
182
|
+
for comp in components_section.find_all("comp"):
|
|
183
|
+
ref_des = self._get_component_ref(comp)
|
|
184
|
+
part_number = self._get_component_part_number(comp)
|
|
185
|
+
|
|
186
|
+
if ref_des and part_number:
|
|
187
|
+
self.components_map[ref_des] = part_number
|
|
188
|
+
|
|
189
|
+
def _get_component_ref(self, comp: SExpression) -> Optional[str]:
|
|
190
|
+
"""Extract reference designator from (comp ...) section."""
|
|
191
|
+
ref_expr = comp.find("ref")
|
|
192
|
+
if ref_expr and len(ref_expr.children) > 1:
|
|
193
|
+
return ref_expr.children[1].value
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def _get_component_part_number(self, comp: SExpression) -> Optional[str]:
|
|
197
|
+
"""
|
|
198
|
+
Extract part number from (comp ...) section.
|
|
199
|
+
Try (value ...) first, then (property (name "PARTNUMBER") ...).
|
|
200
|
+
"""
|
|
201
|
+
# Try (value ...) - most common
|
|
202
|
+
value_expr = comp.find("value")
|
|
203
|
+
if value_expr and len(value_expr.children) > 1:
|
|
204
|
+
part_number = value_expr.children[1].value
|
|
205
|
+
if part_number:
|
|
206
|
+
return part_number
|
|
207
|
+
|
|
208
|
+
# Try (property (name "PARTNUMBER") (value "..."))
|
|
209
|
+
for prop in comp.find_all("property"):
|
|
210
|
+
name_expr = prop.find("name")
|
|
211
|
+
if name_expr and len(name_expr.children) > 1:
|
|
212
|
+
if name_expr.children[1].value == "PARTNUMBER":
|
|
213
|
+
value_expr = prop.find("value")
|
|
214
|
+
if value_expr and len(value_expr.children) > 1:
|
|
215
|
+
return value_expr.children[1].value
|
|
216
|
+
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
def _extract_nets(self, export: SExpression):
|
|
220
|
+
"""Extract nets from (nets ...) section."""
|
|
221
|
+
nets_section = export.find("nets")
|
|
222
|
+
if not nets_section:
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# Find all (net ...) entries
|
|
226
|
+
for net in nets_section.find_all("net"):
|
|
227
|
+
net_name = self._get_net_name(net)
|
|
228
|
+
if not net_name:
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
# Find all (node ...) entries
|
|
232
|
+
for node in net.find_all("node"):
|
|
233
|
+
ref_des, pin = self._get_node_info(node)
|
|
234
|
+
if ref_des and pin:
|
|
235
|
+
self.nets_map[net_name].append((ref_des, pin))
|
|
236
|
+
|
|
237
|
+
def _get_net_name(self, net: SExpression) -> Optional[str]:
|
|
238
|
+
"""Extract net name from (net ...) section."""
|
|
239
|
+
name_expr = net.find("name")
|
|
240
|
+
if name_expr and len(name_expr.children) > 1:
|
|
241
|
+
return name_expr.children[1].value
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
def _get_node_info(self, node: SExpression) -> Tuple[Optional[str], Optional[str]]:
|
|
245
|
+
"""Extract ref_des and pin from (node ...) section."""
|
|
246
|
+
ref_des = None
|
|
247
|
+
pin = None
|
|
248
|
+
|
|
249
|
+
ref_expr = node.find("ref")
|
|
250
|
+
if ref_expr and len(ref_expr.children) > 1:
|
|
251
|
+
ref_des = ref_expr.children[1].value
|
|
252
|
+
|
|
253
|
+
pin_expr = node.find("pin")
|
|
254
|
+
if pin_expr and len(pin_expr.children) > 1:
|
|
255
|
+
pin = pin_expr.children[1].value
|
|
256
|
+
|
|
257
|
+
return ref_des, pin
|
|
258
|
+
|
|
259
|
+
def _build_schema(self) -> NetlistSchema:
|
|
260
|
+
"""Build NetlistSchema from extracted data."""
|
|
261
|
+
# Build pin list per component
|
|
262
|
+
component_pins: Dict[str, List[Pin]] = defaultdict(list)
|
|
263
|
+
|
|
264
|
+
for net_name, nodes in self.nets_map.items():
|
|
265
|
+
for ref_des, pin_number in nodes:
|
|
266
|
+
# Only add pins for known components
|
|
267
|
+
if ref_des in self.components_map:
|
|
268
|
+
pin = Pin(
|
|
269
|
+
pin=pin_number,
|
|
270
|
+
net=net_name
|
|
271
|
+
)
|
|
272
|
+
component_pins[ref_des].append(pin)
|
|
273
|
+
|
|
274
|
+
# Build components list
|
|
275
|
+
components = []
|
|
276
|
+
for ref_des, part_number in self.components_map.items():
|
|
277
|
+
component = Component(
|
|
278
|
+
ref_des=ref_des,
|
|
279
|
+
part_number=part_number,
|
|
280
|
+
pins=component_pins[ref_des]
|
|
281
|
+
)
|
|
282
|
+
components.append(component)
|
|
283
|
+
|
|
284
|
+
return NetlistSchema(components=components)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ============================================================================
|
|
288
|
+
# Main Function
|
|
289
|
+
# ============================================================================
|
|
290
|
+
|
|
291
|
+
def is_kicad_legacy_netlist(text: str) -> bool:
|
|
292
|
+
stripped = text.lstrip()
|
|
293
|
+
if not stripped.startswith("(export"):
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
# MUST contain these blocks
|
|
297
|
+
keywords = ["(design", "(components", "(nets"]
|
|
298
|
+
return all(k in text for k in keywords)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def parse_kicad_legacy_net_file(filepath: Path) -> NetlistSchema:
|
|
302
|
+
"""
|
|
303
|
+
Parse a KiCad legacy netlist file and return validated netlist schema.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
filepath: Path to KiCad legacy netlist file (.net)
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
NetlistSchema with components and pins
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
FileNotFoundError: If file doesn't exist
|
|
313
|
+
ValidationError: If parsed data doesn't match schema
|
|
314
|
+
"""
|
|
315
|
+
if not filepath.exists():
|
|
316
|
+
raise FileNotFoundError(f"KiCad legacy netlist file not found: {filepath}")
|
|
317
|
+
|
|
318
|
+
# Read file content
|
|
319
|
+
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
|
320
|
+
content = f.read()
|
|
321
|
+
|
|
322
|
+
# Parse
|
|
323
|
+
parser = KiCadLegacyNetParser(content)
|
|
324
|
+
schema = parser.parse()
|
|
325
|
+
|
|
326
|
+
return schema
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KiCad SPICE Netlist Parser
|
|
3
|
+
|
|
4
|
+
Parses KiCad-generated SPICE netlist files (.cir) and extracts:
|
|
5
|
+
- Reference designators (ref_des)
|
|
6
|
+
- Part numbers
|
|
7
|
+
- Pin connections (inferred from node positions)
|
|
8
|
+
|
|
9
|
+
SPICE Format:
|
|
10
|
+
RefDes Node1 Node2 ... PartNumber
|
|
11
|
+
Example: R14 Net-_J3-Pad1_ Net-_LED2-A-Pad6_ CRCW0402330RJNED
|
|
12
|
+
|
|
13
|
+
Pin Mapping:
|
|
14
|
+
- 2-terminal components (R, C): pin1=node1, pin2=node2
|
|
15
|
+
- 3-terminal components (Q, transistors): pin1=node1, pin2=node2, pin3=node3
|
|
16
|
+
- Multi-terminal: numbered sequentially
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict
|
|
21
|
+
|
|
22
|
+
from mfcli.models.netlist import Component, NetlistSchema, Pin
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ============================================================================
|
|
26
|
+
# KiCad SPICE Parser
|
|
27
|
+
# ============================================================================
|
|
28
|
+
|
|
29
|
+
class KiCadSpiceParser:
|
|
30
|
+
"""Parser for KiCad SPICE netlist files."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, spice_content: str):
|
|
33
|
+
self.content = spice_content
|
|
34
|
+
self.lines = [line.strip() for line in spice_content.strip().split('\n')]
|
|
35
|
+
self.components: Dict[str, Component] = {}
|
|
36
|
+
|
|
37
|
+
def parse(self) -> NetlistSchema:
|
|
38
|
+
"""Parse SPICE content and return validated schema."""
|
|
39
|
+
for line in self.lines:
|
|
40
|
+
# Skip empty lines
|
|
41
|
+
if not line:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
# Skip SPICE directives (start with .)
|
|
45
|
+
if line.startswith('.'):
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
# Parse component line
|
|
49
|
+
self._parse_component_line(line)
|
|
50
|
+
|
|
51
|
+
# Convert to list and return
|
|
52
|
+
components_list = list(self.components.values())
|
|
53
|
+
return NetlistSchema(components=components_list)
|
|
54
|
+
|
|
55
|
+
def _parse_component_line(self, line: str):
|
|
56
|
+
"""
|
|
57
|
+
Parse a component line from SPICE netlist.
|
|
58
|
+
Format: RefDes Node1 Node2 ... NodeN PartNumber
|
|
59
|
+
"""
|
|
60
|
+
tokens = line.split()
|
|
61
|
+
|
|
62
|
+
if len(tokens) < 2:
|
|
63
|
+
return # Invalid line
|
|
64
|
+
|
|
65
|
+
ref_des = tokens[0]
|
|
66
|
+
|
|
67
|
+
# Last token is part number, middle tokens are nodes
|
|
68
|
+
if len(tokens) == 2:
|
|
69
|
+
# Special case: component with no connections (placeholder)
|
|
70
|
+
# Example: LED2 __LED2
|
|
71
|
+
part_number = tokens[1]
|
|
72
|
+
nodes = []
|
|
73
|
+
else:
|
|
74
|
+
part_number = tokens[-1]
|
|
75
|
+
nodes = tokens[1:-1]
|
|
76
|
+
|
|
77
|
+
# Skip placeholder components (nodes starting with __)
|
|
78
|
+
if nodes and all(node.startswith('__') for node in nodes):
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
# Create component
|
|
82
|
+
component = Component(
|
|
83
|
+
ref_des=ref_des,
|
|
84
|
+
part_number=part_number,
|
|
85
|
+
pins=[]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Map nodes to pins
|
|
89
|
+
# Pin numbers are inferred from node position (1, 2, 3, ...)
|
|
90
|
+
for pin_num, node_name in enumerate(nodes, start=1):
|
|
91
|
+
# Skip placeholder nodes
|
|
92
|
+
if node_name.startswith('__'):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
pin = Pin(
|
|
96
|
+
pin=str(pin_num),
|
|
97
|
+
net=node_name
|
|
98
|
+
)
|
|
99
|
+
component.pins.append(pin)
|
|
100
|
+
|
|
101
|
+
# Only add component if it has pins (skip placeholders)
|
|
102
|
+
if component.pins:
|
|
103
|
+
self.components[ref_des] = component
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ============================================================================
|
|
107
|
+
# Main Function
|
|
108
|
+
# ============================================================================
|
|
109
|
+
|
|
110
|
+
def parse_kicad_spice_file(filepath: Path) -> NetlistSchema:
|
|
111
|
+
"""
|
|
112
|
+
Parse a KiCad SPICE netlist file and return validated netlist schema.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
filepath: Path to KiCad SPICE netlist file (.cir)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
NetlistSchema with components and pins
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
FileNotFoundError: If file doesn't exist
|
|
122
|
+
ValidationError: If parsed data doesn't match schema
|
|
123
|
+
"""
|
|
124
|
+
if not filepath.exists():
|
|
125
|
+
raise FileNotFoundError(f"KiCad SPICE netlist file not found: {filepath}")
|
|
126
|
+
|
|
127
|
+
# Read file content
|
|
128
|
+
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
|
129
|
+
content = f.read()
|
|
130
|
+
|
|
131
|
+
# Parse
|
|
132
|
+
parser = KiCadSpiceParser(content)
|
|
133
|
+
schema = parser.parse()
|
|
134
|
+
|
|
135
|
+
return schema
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PADS Netlist Parser
|
|
4
|
+
|
|
5
|
+
Parses PADS netlist files (Mentor Graphics) and extracts:
|
|
6
|
+
- Reference designators (ref_des)
|
|
7
|
+
- Part numbers/footprints
|
|
8
|
+
- Pin connections (pin number + net name)
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python pads_parser.py <netlist.txt> [--output <output.json>]
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
python pads_parser.py design.asc --output parsed.json
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Dict
|
|
19
|
+
|
|
20
|
+
from mfcli.models.netlist import Component, NetlistSchema, Pin
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ============================================================================
|
|
24
|
+
# PADS Parser
|
|
25
|
+
# ============================================================================
|
|
26
|
+
|
|
27
|
+
class PADSParser:
|
|
28
|
+
"""Parser for PADS netlist files."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, pads_content: str):
|
|
31
|
+
self.content = pads_content
|
|
32
|
+
self.lines = [line.strip() for line in pads_content.strip().split('\n')]
|
|
33
|
+
self.components: Dict[str, Component] = {}
|
|
34
|
+
|
|
35
|
+
def parse(self) -> NetlistSchema:
|
|
36
|
+
"""Parse PADS content and return validated schema."""
|
|
37
|
+
# Step 1: Validate header
|
|
38
|
+
if not self._validate_header():
|
|
39
|
+
raise ValueError("Not a valid PADS netlist file (missing *PADS-PCB* header)")
|
|
40
|
+
|
|
41
|
+
# Step 2: Find section boundaries
|
|
42
|
+
part_start, part_end = self._find_section('*PART*')
|
|
43
|
+
net_start, net_end = self._find_section('*NET*')
|
|
44
|
+
|
|
45
|
+
# Step 3: Parse components
|
|
46
|
+
if part_start is not None and part_end is not None:
|
|
47
|
+
self._parse_parts(part_start, part_end)
|
|
48
|
+
|
|
49
|
+
# Step 4: Parse nets and add pins to components
|
|
50
|
+
if net_start is not None and net_end is not None:
|
|
51
|
+
self._parse_nets(net_start, net_end)
|
|
52
|
+
|
|
53
|
+
# Step 5: Validate and return
|
|
54
|
+
components_list = list(self.components.values())
|
|
55
|
+
return NetlistSchema(components=components_list)
|
|
56
|
+
|
|
57
|
+
def _validate_header(self) -> bool:
|
|
58
|
+
"""Check if file has PADS header."""
|
|
59
|
+
return len(self.lines) > 0 and self.lines[0].startswith('*PADS-PCB*')
|
|
60
|
+
|
|
61
|
+
def _find_section(self, section_name: str) -> tuple:
|
|
62
|
+
"""Find start and end indices of a section."""
|
|
63
|
+
try:
|
|
64
|
+
start_idx = self.lines.index(section_name) + 1
|
|
65
|
+
|
|
66
|
+
# Find end of section (next TOP-LEVEL section marker or *END*)
|
|
67
|
+
# Top-level markers: *PART*, *NET*, *END* (not *SIGNAL*)
|
|
68
|
+
top_level_markers = ['*PART*', '*NET*', '*END*']
|
|
69
|
+
end_idx = len(self.lines)
|
|
70
|
+
for i in range(start_idx, len(self.lines)):
|
|
71
|
+
if self.lines[i] in top_level_markers and self.lines[i] != section_name:
|
|
72
|
+
end_idx = i
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
return start_idx, end_idx
|
|
76
|
+
except ValueError:
|
|
77
|
+
return None, None
|
|
78
|
+
|
|
79
|
+
def _parse_parts(self, start_idx: int, end_idx: int):
|
|
80
|
+
"""Parse the *PART* section to extract components."""
|
|
81
|
+
for i in range(start_idx, end_idx):
|
|
82
|
+
line = self.lines[i]
|
|
83
|
+
|
|
84
|
+
# Skip empty lines and section markers
|
|
85
|
+
if not line or line.startswith('*'):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Split line into ref_des and part_number
|
|
89
|
+
parts = line.split(None, 1) # Split on first whitespace
|
|
90
|
+
if len(parts) >= 1:
|
|
91
|
+
ref_des = parts[0]
|
|
92
|
+
part_number = parts[1] if len(parts) > 1 else ""
|
|
93
|
+
|
|
94
|
+
# Create component
|
|
95
|
+
self.components[ref_des] = Component(
|
|
96
|
+
ref_des=ref_des,
|
|
97
|
+
part_number=part_number,
|
|
98
|
+
pins=[]
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _parse_nets(self, start_idx: int, end_idx: int):
|
|
102
|
+
"""Parse the *NET* section to extract pin connections."""
|
|
103
|
+
current_net = None
|
|
104
|
+
|
|
105
|
+
for i in range(start_idx, end_idx):
|
|
106
|
+
line = self.lines[i]
|
|
107
|
+
|
|
108
|
+
# Skip empty lines
|
|
109
|
+
if not line:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
# Check for signal/net definition
|
|
113
|
+
if line.startswith('*SIGNAL*'):
|
|
114
|
+
# Extract net name (everything after *SIGNAL*)
|
|
115
|
+
parts = line.split(None, 1)
|
|
116
|
+
if len(parts) > 1:
|
|
117
|
+
current_net = parts[1]
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Skip other section markers
|
|
121
|
+
if line.startswith('*'):
|
|
122
|
+
current_net = None
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# Parse pin references if we have a current net
|
|
126
|
+
if current_net:
|
|
127
|
+
self._parse_pin_references(line, current_net)
|
|
128
|
+
|
|
129
|
+
def _parse_pin_references(self, line: str, net_name: str):
|
|
130
|
+
"""Parse pin references from a line and add to components."""
|
|
131
|
+
# Split line into individual pin references
|
|
132
|
+
pin_refs = line.split()
|
|
133
|
+
|
|
134
|
+
for pin_ref in pin_refs:
|
|
135
|
+
# Skip empty strings
|
|
136
|
+
if not pin_ref:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Pin format: RefDes.PinNumber (e.g., C1.2, R14.1)
|
|
140
|
+
if '.' in pin_ref:
|
|
141
|
+
parts = pin_ref.split('.', 1)
|
|
142
|
+
ref_des = parts[0]
|
|
143
|
+
pin_number = parts[1]
|
|
144
|
+
|
|
145
|
+
# Add pin to component if component exists
|
|
146
|
+
if ref_des in self.components:
|
|
147
|
+
pin = Pin(pin=pin_number, net=net_name)
|
|
148
|
+
|
|
149
|
+
# Avoid duplicates
|
|
150
|
+
existing_pins = self.components[ref_des].pins
|
|
151
|
+
if not any(p.pin == pin.pin and p.net == pin.net for p in existing_pins):
|
|
152
|
+
self.components[ref_des].pins.append(pin)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ============================================================================
|
|
156
|
+
# Main Functions
|
|
157
|
+
# ============================================================================
|
|
158
|
+
|
|
159
|
+
def parse_pads_file(filepath: Path) -> NetlistSchema:
|
|
160
|
+
"""
|
|
161
|
+
Parse a PADS netlist file and return validated netlist schema.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
filepath: Path to PADS netlist file
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
NetlistSchema with components and pins
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
FileNotFoundError: If file doesn't exist
|
|
171
|
+
ValidationError: If parsed data doesn't match schema
|
|
172
|
+
ValueError: If file is not a valid PADS netlist
|
|
173
|
+
"""
|
|
174
|
+
if not filepath.exists():
|
|
175
|
+
raise FileNotFoundError(f"PADS netlist file not found: {filepath}")
|
|
176
|
+
|
|
177
|
+
# Read file content
|
|
178
|
+
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
|
179
|
+
content = f.read()
|
|
180
|
+
|
|
181
|
+
# Parse
|
|
182
|
+
parser = PADSParser(content)
|
|
183
|
+
schema = parser.parse()
|
|
184
|
+
|
|
185
|
+
return schema
|