Python Functions, Classes, and Dataclasses for AutoLISP Programmers
How Python's def, class, and @dataclass map to AutoLISP defun and list-based data — translated for solar engineers who think in LISP and need to read modern Python code for AutoCAD work.
Python Functions, Classes, and Dataclasses for AutoLISP Programmers
You ran your first Python script. You can read import csv and with open(...) as f: and follow what's happening. Then you opened a real Python file from a solar tutorial — maybe a stringing script — and saw class Panel:, @dataclass, def __init__(self, ...), self.x = x. The LISP analogies stop working. This is where most LISP-to-Python crossings get stuck.
By the end of this post you'll be able to read a Python class, write a dataclass, and stop being intimidated by self. Every concept gets a LISP equivalent first, then the places where the analogy breaks. Plan for 25 minutes.
Functions: defun → def
In LISP, you define a function with defun. In Python, you use def. The structure is nearly identical.
(defun calc-string-length (panel-count panel-width spacing)
(+ (* panel-count panel-width)
(* (- panel-count 1) spacing)))
def calc_string_length(panel_count, panel_width, spacing):
return panel_count * panel_width + (panel_count - 1) * spacing
The correspondence is exact:
defun→def- Parameters in parentheses → parameters in parentheses (same idea, no quotes around the name)
- LISP returns the value of the last expression automatically → Python requires an explicit
returnkeyword - Parentheses group the arithmetic in LISP → indentation and operator precedence do it in Python
The indentation rule is the one that surprises LISP programmers most. Python uses the level of indentation to define what belongs inside a function — no closing parenthesis, no end. Mess up the indentation and you get an IndentationError. VS Code handles this automatically with Tab, so in practice it's not a problem.
Python adds two features you'll see in real code that LISP doesn't have cleanly:
Default values. You can make a parameter optional by giving it a default:
def calc_string_length(panel_count, panel_width=1.0, spacing=0.05):
return panel_count * panel_width + (panel_count - 1) * spacing
Callers can omit panel_width and spacing and get the defaults. In LISP you'd handle this with (if (null panel-width) (setq panel-width 1.0) ...). The Python version is cleaner.
Type hints. Modern Python code annotates parameter types. The hints are optional — Python won't enforce them at runtime — but you'll see them everywhere:
def calc_string_length(panel_count: int, panel_width: float = 1.0, spacing: float = 0.05) -> float:
return panel_count * panel_width + (panel_count - 1) * spacing
The -> float says the function returns a float. Read these as documentation, not enforcement.
Lists: LISP lists → Python lists
LISP is built around lists. Python has lists too, but they work differently.
(setq panels '("P001" "P002" "P003"))
(car panels) ; "P001"
(cdr panels) ; ("P002" "P003")
(length panels) ; 3
panels = ["P001", "P002", "P003"]
panels[0] # "P001"
panels[1:] # ["P002", "P003"]
len(panels) # 3
The differences:
- Square brackets instead of parens
- Index by position with
[n]— zero-indexed, so the first element is[0], not(car) panels[1:]is Python's slice syntax. Read it as "from index 1 to the end."panels[1:3]would give you indices 1 and 2. There is no direct LISP equivalent that's as concise — slicing is one of Python's genuinely better ideaslen(panels)instead of(length panels)- Lists can be modified in place:
panels.append("P004")adds to the end. LISP lists are typically built up withcons, not mutated
One thing to keep in mind: Python lists can hold mixed types ([1, "hello", 3.14]), but in production code they shouldn't. If you have a list of panel IDs, every element should be a string. Mixed-type lists will work until they don't, and the error you get is confusing.
Dictionaries: the data structure LISP doesn't have cleanly
LISP programmers have probably faked dictionaries with association lists — (list (cons 'id "P001") (cons 'x 10.5)) — and retrieved values with (cdr (assoc 'x panel)). It works, but it's not first-class. Python has dictionaries as a built-in type and uses them constantly.
panel = {"id": "P001", "x": 10.5, "y": 20.3, "type": "REC400"}
panel["id"] # "P001"
panel["x"] = 11.0 # update existing key
panel["mppt"] = 1 # add a new key
"id" in panel # True
This matters because every CSV row, every JSON object, every API response is a dictionary. The csv.DictReader from your first script was handing you one of these for each row. You will see this data structure in every Python file you read.
The LISP assoc equivalent is panel["key"]. The difference: Python raises a KeyError immediately if the key doesn't exist, whereas (assoc 'missing panel) silently returns nil. Python's behavior is usually better — it fails loud and fast rather than propagating a nil downstream.
If you want a safe lookup that returns a default instead of an error, use .get():
panel.get("mppt", 1) # returns panel["mppt"] if it exists, else 1
The OOP wall: classes
This is where most LISP programmers stall. A class is a template for making objects. Each object made from the class has its own copy of the data and shares the same methods (functions that operate on the data).
The reason this maps awkwardly from LISP is that LISP separates data from the functions that operate on it. A panel is a list of property pairs. The functions that work with panels are free-standing defun calls that take the panel list as their first argument:
(defun make-panel (id x y type)
(list (cons 'id id) (cons 'x x) (cons 'y y) (cons 'type type)))
(defun panel-x (panel)
(cdr (assoc 'x panel)))
(setq p1 (make-panel "P001" 10.5 20.3 "REC400"))
(panel-x p1) ; 10.5
In Python, you bundle the data and the functions that operate on it into one thing called a class:
class Panel:
def __init__(self, panel_id, x, y, panel_type):
self.id = panel_id
self.x = x
self.y = y
self.type = panel_type
p1 = Panel("P001", 10.5, 20.3, "REC400")
p1.x # 10.5
Walk through each piece:
class Panel:— declare a new type calledPanel. Everything indented underneath it belongs to the class.def __init__(self, ...)— the constructor. Python calls this automatically when you writePanel(...). The double underscores (called "dunder" in Python circles) mean Python uses this method name internally — you don't call__init__directly, you just writePanel(...)and Python handles it.self— the object being constructed. It is the Python equivalent of passingpanelas the first argument to every LISP function that operates on a panel. Every method on a class getsselfas its first parameter, and Python passes it automatically — you never supply it when calling the method.self.id = panel_id— store an attribute on the object. The equivalent of(cons 'id id)in your LISP property list. After construction,p1.idretrieves it, just like(cdr (assoc 'id p1))in LISP, but without thecdr/assocceremony.
The mental shift from LISP: the data lives inside the object. Functions that operate on it are written inside the class definition and travel with it. Any module that imports your Panel class gets both the data structure and the operations together.
@dataclass: the shortcut you actually want
The __init__ boilerplate in the example above gets repetitive quickly. For every attribute you add, you write self.x = x twice — once in the parameter list, once in the assignment. Python has a decorator called @dataclass that writes that boilerplate for you.
from dataclasses import dataclass
@dataclass
class Panel:
id: str
x: float
y: float
type: str
p1 = Panel("P001", 10.5, 20.3, "REC400")
p1.x # 10.5
That is the same class. Four field declarations instead of eight lines of constructor code. Python reads the type hints, auto-generates __init__, and also generates a __repr__ — which means print(p1) shows Panel(id='P001', x=10.5, y=20.3, type='REC400') instead of <Panel object at 0x7f3c...>.
@dataclass is everywhere in modern Python code (Python 3.7+, released 2018). For anything you write yourself, start with it. The manual __init__ version is worth being able to read — library code from before 2018 and some frameworks use it — but you rarely need to write it.
The @ syntax is a decorator. Read it as "apply this transformation to the class that follows." The details of how decorators work aren't necessary to use them — the important part is recognizing that @dataclass before a class declaration means Python is handling the setup code for you.
A method on a class
Adding a method to a class means adding a def inside the class body:
@dataclass
class Panel:
id: str
x: float
y: float
type: str
def distance_to(self, other):
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
p1 = Panel("P001", 10.5, 20.3, "REC400")
p2 = Panel("P002", 12.0, 20.3, "REC400")
p1.distance_to(p2) # 1.5
self is the panel you're calling the method on (p1). other is the panel you're passing in (p2). The dot notation — p1.distance_to(p2) — reads as "call distance_to on p1, passing p2."
In LISP this would have been a free-standing function:
(defun panel-distance (p1 p2)
(sqrt (+ (expt (- (cdr (assoc 'x p1)) (cdr (assoc 'x p2))) 2)
(expt (- (cdr (assoc 'y p1)) (cdr (assoc 'y p2))) 2))))
(panel-distance p1 p2) ; 1.5
The computation is the same. The Python version puts the function inside the class so it travels with the data. When you pass a Panel to another function, the distance_to method comes along.
Reading a typical solar Python script
Here's a real-world snippet you'd find in any solar Python codebase. This is the kind of thing that looks like a wall of syntax until you know what each part does:
from dataclasses import dataclass, field
from typing import List
@dataclass
class String:
string_id: int
panels: List[Panel] = field(default_factory=list)
mppt_input: int = 1
def total_modules(self):
return len(self.panels)
def add_panel(self, panel: Panel):
self.panels.append(panel)
Line by line:
from typing import List— type hints for collections. In Python 3.8 and earlier, you writeList[Panel]to say "a list of Panel objects." In Python 3.9+, you can writelist[Panel]directly. You'll see both in the wild.panels: List[Panel] = field(default_factory=list)— thefield(default_factory=list)part tells Python "give eachStringinstance its own empty list." If you wrotepanels: List[Panel] = []instead, everyStringwould share the same list — a subtle but destructive bug.field(default_factory=...)is the correct way to set a mutable default in a dataclass.mppt_input: int = 1— a default value. AnyStringcreated without specifyingmppt_inputgets1.self.panels.append(panel)— modifies the list belonging to this specificStringinstance, not some shared list.
If you can read that snippet, you can read 80% of the solar Python code on GitHub. The remaining 20% is third-party libraries — numpy, pdfplumber, shapely — which you pick up one at a time as you need them.
Where to go next
- Reading SolarEdge PDFs in Python: A Beginner's Guide to pdfplumber — your first third-party library. Parses a real SolarEdge Designer report and uses the data structures from this post. (~30 minutes)
- Stringing Solar Panels for AutoCAD with Python — a 200-line working solver that uses
@dataclassthroughout. Now that you know whatPanel,SolverConfig, andStringResultmean as dataclasses, the code reads cleanly. (~45 minutes) - Parsing SolarEdge Designer PDFs for AutoCAD with Python and pdfplumber — production-grade PDF extraction with all four pitfalls handled. The code here is more complex, but the class and dataclass patterns are the same ones from this post. (~45 minutes)
If you'd rather skip the code entirely — Branch handles the PDF import, string routing, and AutoCAD drafting without you writing a line of Python. See how it works at /product.
- Reading SolarEdge PDFs in Python: A Beginner's Guide to pdfplumberHow to install pdfplumber, open a SolarEdge Designer PDF, and extract text and tables for AutoCAD import — a friendly introduction before tackling the production pitfalls.Python Onramp~30 min read
- Stringing Solar Panels for AutoCAD with Python: A 200-Line Solver You Can Run TodayA working Python solver for grouping solar panels into strings for AutoCAD drafting — what it does, what it leaves on the table, and why production tools stack four algorithms instead of one.Deep Dive~45 min read