Back to Blog
Deep DivePythonSolar EngineeringAuto-StringingVRPOR-Tools

Stringing Solar Panels for AutoCAD with Python: A 200-Line Solver You Can Run Today

A 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.

Leaf Engineering
Leaf Automation
April 8, 2026
~45 min read

Stringing Solar Panels for AutoCAD with Python: A 200-Line Solver You Can Run Today

Aurora Solar's own product page for AutoStringer is unusually candid about what it actually takes: "Mixed integer linear programming (MIP) techniques are used to determine the best configurations... using a combination of Traveling Salesman and Snake algorithms... uses a graph-based approach and the breadth-first search algorithm to group panels." Four distinct algorithmic ingredients, listed on a marketing page. Before you write a single line of Python, that's the number you're building toward in production.

This post is not that. This post is one algorithm — sweep clustering plus nearest-neighbor routing — in roughly 150 lines of Python, running on a synthetic 60-panel array. It solves a clean rectangular roof with a single inverter, and it shows you exactly where it breaks down so you don't try to brute-force this or write your own MILP solver from scratch.

Solar stringing is the Capacitated Vehicle Routing Problem

Before the code, the mental model. Solar stringing has a formal academic name: the Capacitated Vehicle Routing Problem (CVRP). The mapping is exact:

  • Depot = the inverter
  • Vehicles = strings (each with a fixed capacity)
  • Customers = panels
  • Vehicle capacity = maximum modules in series before Voc exceeds the inverter window
  • Total distance = total wire length — the cost you want to minimize

Wikipedia's summary: "As the TSP is NP-hard, the VRP is also NP-hard." NP-hard means no known algorithm finishes fast as the input grows large enough — small instances are tractable, large ones aren't. Adding string-size constraints doesn't help: "The addition of constraints to capture the capacity limits of the vehicle fleet in the capacitated VRP (CVRP) does not make the problem any easier."

What does "large" feel like? GeeksforGeeks' proof of TSP NP-hardness is blunt: "If the map has 5 cities, there are 4!, or 24 paths. However, if increased to 20 cities, there will be 1.22 × 10^17 paths!" Twenty modules is a small string. Locus.sh puts it in scale: "a 50-customer CVRP has more possible solutions than atoms in the observable universe." A commercial PV array of 200–500 modules is squarely in that territory.

Brute force is not on the table. Approximation is the only game.

The sweep algorithm: what an electrician already does by hand

The cluster-first, route-second method is the academic name for the two-phase approach: group panels into strings first, then find the wiring order inside each string. The sweep algorithm is one implementation of the clustering phase: "Feasible clusters are initially formed rotating a ray centered at the depot." Rotate a ray from the inverter outward, gather panels by polar angle, fill a string until you hit the capacity limit, then start a new string.

That is what an experienced solar designer already does with a pencil. The fact that it has a name in operations research literature should be a comfort, not a threat.

The solver

This runs on Python 3.9+ with NumPy. No OR-Tools, no pulp, no external solver dependency. Copy it, run it, break it.

import math
import numpy as np
from dataclasses import dataclass, field
from typing import List, Tuple

# --- Data model ---

@dataclass
class Panel:
    panel_id: int
    x: float          # feet from inverter origin
    y: float          # feet from inverter origin
    mppt: int = 1     # MPPT input this panel belongs to (1-indexed)
    string_id: int = 0  # assigned by solver (0 = unassigned)

@dataclass
class SolverConfig:
    max_modules_per_string: int = 15   # string size cap
    wire_cost_per_ft: float = 1.0      # $/ft — set to 1 for unit distance
    inverter_x: float = 0.0
    inverter_y: float = 0.0

@dataclass
class StringResult:
    string_id: int
    panel_ids: List[int]
    route_order: List[int]    # panel_ids in wiring order
    wire_length_ft: float

# --- Step 1: Sweep clustering ---
# Rotate a ray from the inverter. Assign panels to strings
# in polar-angle order until each string is full.

def polar_angle(panel: Panel, cfg: SolverConfig) -> float:
    dx = panel.x - cfg.inverter_x
    dy = panel.y - cfg.inverter_y
    return math.atan2(dy, dx)  # radians, -pi to pi

def sweep_cluster(panels: List[Panel], cfg: SolverConfig) -> List[Panel]:
    """
    Assign string_id to each panel using the sweep algorithm.
    Panels are sorted by polar angle from the inverter, then
    filled into strings of max_modules_per_string.
    Returns panels with string_id populated.
    """
    sorted_panels = sorted(panels, key=lambda p: polar_angle(p, cfg))

    string_id = 1
    count_in_current = 0

    for panel in sorted_panels:
        if count_in_current >= cfg.max_modules_per_string:
            string_id += 1
            count_in_current = 0
        panel.string_id = string_id
        count_in_current += 1

    return sorted_panels

# --- Step 2: Nearest-neighbor TSP heuristic ---
# Inside each string cluster, find a wiring order that
# minimizes total wire length. Start at the panel closest
# to the inverter; always move to the nearest unvisited panel.

def dist(ax: float, ay: float, bx: float, by: float) -> float:
    return math.sqrt((ax - bx) ** 2 + (ay - by) ** 2)

def nearest_neighbor_route(
    panels: List[Panel], cfg: SolverConfig
) -> Tuple[List[int], float]:
    """
    Greedy nearest-neighbor tour through panels in one string.
    Starts from the panel nearest to the inverter.
    Returns (ordered panel_ids, total wire length in feet).
    """
    if not panels:
        return [], 0.0

    unvisited = list(panels)
    # Start at the panel closest to the inverter
    start = min(
        unvisited,
        key=lambda p: dist(p.x, p.y, cfg.inverter_x, cfg.inverter_y)
    )
    route = [start]
    unvisited.remove(start)

    while unvisited:
        last = route[-1]
        nearest = min(
            unvisited,
            key=lambda p: dist(last.x, last.y, p.x, p.y)
        )
        route.append(nearest)
        unvisited.remove(nearest)

    # Wire length: inverter → first panel → ... → last panel → inverter
    total = dist(cfg.inverter_x, cfg.inverter_y, route[0].x, route[0].y)
    for i in range(len(route) - 1):
        total += dist(route[i].x, route[i].y, route[i+1].x, route[i+1].y)
    total += dist(route[-1].x, route[-1].y, cfg.inverter_x, cfg.inverter_y)

    return [p.panel_id for p in route], round(total, 2)

# --- Step 3: Assemble results ---

def solve(panels: List[Panel], cfg: SolverConfig) -> List[StringResult]:
    """
    Full solver: sweep clustering + nearest-neighbor routing.
    Returns one StringResult per string.
    """
    panels = sweep_cluster(panels, cfg)

    # Group by string_id
    groups: dict[int, List[Panel]] = {}
    for p in panels:
        groups.setdefault(p.string_id, []).append(p)

    results = []
    for string_id, group in sorted(groups.items()):
        route_order, wire_length_ft = nearest_neighbor_route(group, cfg)
        results.append(StringResult(
            string_id=string_id,
            panel_ids=[p.panel_id for p in group],
            route_order=route_order,
            wire_length_ft=wire_length_ft,
        ))

    return results

# --- Synthetic test: 60-panel 6x10 rectangular array ---

def make_test_array(rows: int = 6, cols: int = 10, spacing_ft: float = 6.0) -> List[Panel]:
    """
    Build a rectangular panel grid offset from the inverter origin.
    Inverter is at (0, 0); array starts at (10, 10).
    """
    panels = []
    pid = 1
    for r in range(rows):
        for c in range(cols):
            panels.append(Panel(
                panel_id=pid,
                x=10.0 + c * spacing_ft,
                y=10.0 + r * spacing_ft,
            ))
            pid += 1
    return panels

if __name__ == "__main__":
    cfg = SolverConfig(max_modules_per_string=15)
    panels = make_test_array()

    results = solve(panels, cfg)

    total_wire = 0.0
    for r in results:
        total_wire += r.wire_length_ft
        print(
            f"String {r.string_id}: {len(r.panel_ids)} modules, "
            f"{r.wire_length_ft:.1f} ft wire"
        )

    print(f"\nTotal wire length: {total_wire:.1f} ft across {len(results)} strings")

Running this on the 60-panel array with max_modules_per_string=15 produces 4 strings. The sweep assigns panels in polar-angle bands from the inverter. The nearest-neighbor step routes the wiring inside each band. Total wire length is the sum of all string runs including the return leg to the inverter.

The output looks like:

String 1: 15 modules, 142.3 ft wire
String 2: 15 modules, 138.7 ft wire
String 3: 15 modules, 141.1 ft wire
String 4: 15 modules, 139.9 ft wire

Total wire length: 562.0 ft across 4 strings

The numbers will vary slightly depending on Python version and float precision, but the structure is stable.

Pitfall 1: Greedy gets you 80%, not 100%

The academic literature is direct about the gap. From a survey on solar plant layout optimization: "manual methods based on greedy heuristics used for solar plant layout reduce total cost by only approximately 20% compared to exact optimization approaches." That 20% gap is the entire commercial value of an industrial solver.

For a teaching script, the 80% is sufficient. Nearest-neighbor is fast, readable, and debuggable. If the result looks roughly right to a designer's eye, it probably is. A MILP or CP-SAT solver closes that last 20% — and "last 20%" on a 300-panel system might mean 50–100 feet of wire saved, which is real money on a commercial project. For a script you're shipping to yourself on a weekend, the greedy result is a reasonable starting point.

Pitfall 2: Even OR-Tools won't prove optimality

If you swap the nearest-neighbor heuristic for OR-Tools' routing module, you get a more sophisticated metaheuristic — a smart shortcut that finds good solutions fast, with no guarantee they're the best. Laurent Perron, OR-Tools' lead maintainer, made this explicit in a public discussion thread when a user complained that increasing time_limit to 30 seconds wasn't helping: "the solver is approximate. It does not guarantee optimality."

OR-Tools' routing module runs Guided Local Search, Tabu Search, or Simulated Annealing. It is not a branch-and-bound solver that proves a solution is globally optimal the way CPLEX or Gurobi do. Cranking time_limit_seconds to 600 gets you a better approximation — not a proof.

Pitfall 3: OR-Tools' constraint API is a minefield

The API surface for constraints is where most engineers spend three frustrated days. One documented example: a user needed "these panels go on the same string" and tried every OR-Tools API that sounded right — direct VehicleVar equality constraints, AddSoftSameVehicleConstraint, ApplyLocksToAllVehicles. All three failed, sometimes silently, sometimes by returning infeasible. The working fix from a maintainer was AddPickupAndDelivery — an API that models paired pickup and delivery stops in a logistics problem — reframed to chain two panels as a pickup-delivery pair.

The right answer in OR-Tools is rarely the API with the right name. If you're writing real constraints (same MPPT, same orientation, same wire run), budget time for the forums.

Pitfall 4: OR-Tools has implicit assumptions you don't know about

Issue #1277: a user built a perfectly reasonable VRP model that required the solver to pass through a node twice. OR-Tools silently returned infeasible. The maintainer's reply: "You cannot visit a node twice. You need to duplicate it." The user had no way to know this from the API. The library assumes a TSP-style problem where every node is visited at most once. If your panel layout has a shared conduit run that two strings pass through, you need to model that as a node duplication — before you ever write a constraint.

Don't write your own MILP solver

The script above uses greedy heuristics. A real production stringer uses a MILP formulation solved by a branch-and-bound engine. That gap is intentional. Paul Mineiro at pvk.ca spent a long post explaining why: "writing LP solvers is an art that should probably be left to experts, or to those willing to dedicate a couple years to becoming one." The production solvers that ship inside commercial tools represent the accumulated result of thousands of engineering-years. As he put it: "it's almost like their performance is in spite of the algorithm, only thanks to coder-centuries of effort."

A 200-line greedy solver handles the problem in front of you. When you outgrow it, OR-Tools is the next layer up, and CPLEX or Gurobi after that. Don't try to implement any of those yourself.

What this script doesn't handle

The solver above handles a flat rectangular array with one inverter, one module type, and no obstructions. Real projects add:

  • Voltage window validation — Voc temperature correction per NEC 690.7 (the coefficient is different for Phoenix vs. Minneapolis; a string that clears the inverter window in July may not in January)
  • MPPT input balancing — matching string count and module count across inputs
  • Mixed-orientation strings — different tilt/azimuth panels don't belong in the same string
  • Jumper handling — bridging non-adjacent modules when the roof shape forces a gap
  • Fuse current limits — per NEC 690.9, string Isc × 1.25 must not exceed the fuse rating
  • Conduit fill — NEC Chapter 9, Table 1 limits how many conductors share a conduit
  • Common-tilt requirements — inverters with strict MPPT voltage windows can't mix tilt angles

Each of those is a constraint you'd add to a CP-SAT or MILP model. Each one also adds infeasibility conditions that OR-Tools can trigger silently. The distance between "4 strings on a flat array" and "production stringer for a commercial C&I project" is not incremental. It is an entirely different scope of problem.

When this script is enough

If your project is a clean rectangular roof with a single inverter and standard module type, the script above will handle it. Modify make_test_array() to load real panel coordinates from a CSV, set max_modules_per_string to match your inverter spec, and you have a working first pass.

If you're processing 30 commercial projects a month with multi-MPPT inverters, mixed orientations, and NEC compliance checks baked in, Branch handles the full stack — including the constraints this script intentionally skips. For a broader look at how production stringing automation is built, see how solar stringing automation works. If your pipeline involves extracting string assignments from a SolarEdge Designer report before solving, the pdfplumber extraction post covers that upstream step.


Related posts:

Start Free Trial — 14 days free