Files
PVPlant/Export/PVPlantBOQMechanical.py
2025-06-15 23:10:17 +02:00

361 lines
14 KiB
Python

# /**********************************************************************
# * *
# * Copyright (c) 2021 Javier Braña <javier.branagutierrez@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify*
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307*
# * USA *
# * *
# ***********************************************************************
import os
import platform
import subprocess
from typing import Dict, List
import FreeCAD
import openpyxl
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.workbook import Workbook
from openpyxl.worksheet.worksheet import Worksheet
import PVPlantResources
import PVPlantSite
if FreeCAD.GuiUp:
from PySide.QtCore import QT_TRANSLATE_NOOP
__title__ = "PVPlant Mechanical BOQ"
__author__ = "Javier Braña"
__url__ = "http://www.sogos-solar.com"
# Constants
SCALE = 0.001 # Millimeters to meters
THIN_BORDER = Border(
top=Side(border_style="thin", color="7DA4B8"),
left=Side(border_style="thin", color="7DA4B8"),
right=Side(border_style="thin", color="7DA4B8"),
bottom=Side(border_style="thin", color="7DA4B8")
)
HEADER_FILL = PatternFill("solid", fgColor="7DA4B8")
HEADER_FONT = Font(name='Arial', size=10, bold=True, color="FFFFFF")
DATA_FONT = Font(name='Arial', size=10)
CENTER_ALIGN = Alignment(horizontal="center", vertical="center")
# Estilos:
thin = Side(border_style="thin", color="7DA4B8")
double = Side(border_style="double", color="ff0000")
border_thin = Border(top=thin, left=thin, right=thin, bottom=thin)
border_fat = Border(top=thin, left=thin, right=thin, bottom=thin)
# fill = PatternFill("solid", fgColor="DDDDDD")
# fill = GradientFill(stop=("000000", "FFFFFF"))
scale = 0.001 # milimeters to meter
def open_file(path: str) -> None:
"""Open a file or directory using the system's default handler"""
if platform.system() == "Windows":
os.startfile(path)
elif platform.system() == "Darwin":
subprocess.Popen(["open", path])
else:
subprocess.Popen(["xdg-open", path])
def style_range(ws, cell_range, border=Border(), fill=None, font=None, alignment=None):
"""
Apply styles to a range of cells as if they were a single cell.
:param ws: Excel worksheet instance
:param range: An excel range to style (e.g. A1:F20)
:param border: An openpyxl Border
:param fill: An openpyxl PatternFill or GradientFill
:param font: An openpyxl Font object
"""
top = Border(top=border.top)
left = Border(left=border.left)
right = Border(right=border.right)
bottom = Border(bottom=border.bottom)
first_cell = ws[cell_range.split(":")[0]]
if alignment:
first_cell.alignment = alignment
rows = ws[cell_range]
if font:
first_cell.font = font
for cell in rows[0]:
cell.border = cell.border + top
for cell in rows[-1]:
cell.border = cell.border + bottom
for row in rows:
l = row[0]
r = row[-1]
l.border = l.border + left
r.border = r.border + right
if fill:
for c in row:
c.fill = fill
def create_sheet_headers(sheet: Worksheet, headers: List[str], widths: Dict[str, float]) -> None:
"""Create standardized sheet headers."""
for col, header in enumerate(headers, start=1):
cell = sheet.cell(row=1, column=col, value=header)
cell.fill = HEADER_FILL
cell.font = HEADER_FONT
cell.alignment = CENTER_ALIGN
for col, width in widths.items():
sheet.column_dimensions[col].width = width
sheet.row_dimensions[1].height = 30
style_range(sheet, f'A1:{chr(64 + len(headers))}1',
border=THIN_BORDER,
fill=HEADER_FILL,
font=HEADER_FONT,
alignment=CENTER_ALIGN)
def spreadsheetBOQFrames(sheet: Worksheet, selection: List[FreeCAD.DocumentObject]) -> None:
"""Generate frames information sheet."""
headers = [
'Index', 'Frame', 'Frame Type', 'X (m)', 'Y (m)', 'Z (m)',
'Angle N-S', 'Angle L-W', 'Nº Poles'
]
widths = {'A': 8, 'B': 30, 'C': 20, 'D': 15, 'E': 15,
'F': 15, 'G': 15, 'H': 15, 'I': 10}
create_sheet_headers(sheet, headers, widths)
for idx, obj in enumerate(selection, start=2):
placement = obj.Placement
sheet[f'A{idx}'] = idx - 1
sheet[f'B{idx}'] = obj.Label
sheet[f'C{idx}'] = obj.Setup.Label
sheet[f'D{idx}'] = placement.Base.x * SCALE
sheet[f'E{idx}'] = placement.Base.y * SCALE
sheet[f'F{idx}'] = placement.Base.z * SCALE
sheet[f'G{idx}'] = placement.Rotation.toEuler()[0]
sheet[f'H{idx}'] = placement.Rotation.toEuler()[1]
sheet[f'I{idx}'] = obj.Setup.NumberPole.Value
style_range(sheet, f'A{idx}:I{idx}',
border=THIN_BORDER,
font=DATA_FONT,
alignment=CENTER_ALIGN)
def spreadsheetBOQPoles(sheet: Worksheet, selection: List[FreeCAD.DocumentObject]) -> None:
"""Generate poles information sheet."""
headers = [
'Frame', 'Pole', 'Pole Type', 'X (m)', 'Y (m)', 'Z Frame Attach (m)',
'Z Aerial Head (m)', 'Pole Length (m)', 'Aerial Length (m)',
'Terrain Enter Length (m)'
]
widths = {chr(65 + i): 20 for i in range(10)}
widths['A'] = 30
widths['B'] = 10
create_sheet_headers(sheet, headers, widths)
sheet.row_dimensions[2].height = 5
import MeshPart as mp
from Mechanical.Frame import PVPlantFrame
# Data:
terrain = PVPlantSite.get().Terrain.Mesh # Shape
poles_data = []
for frame in selection:
try:
poles = frame.Shape.SubShapes[1].SubShapes[0].SubShapes
num_poles = int(frame.Setup.NumberPole.Value)
sequence = frame.Setup.PoleSequence
if len(sequence) < num_poles:
sequence = PVPlantFrame.getarray(frame.Setup.PoleSequence, num_poles)
for pole_idx in range(num_poles):
pole = poles[pole_idx]
pole_type = frame.Setup.PoleType[sequence[pole_idx]]
center = pole.BoundBox.Center
poles_data.append({
'frame': frame.Label,
'number': pole_idx + 1,
'type': pole_type.Label,
'center': center,
'head_z': pole.BoundBox.ZMax,
'length': pole_type.Height.Value
})
except Exception as e:
FreeCAD.Console.PrintError(f"Error processing frame {frame.Label}: {str(e)}\n")
# Project points on terrain
try:
import MeshPart
points = [data['center'] for data in poles_data]
terrain_z = MeshPart.projectPointsOnMesh(points, terrain, FreeCAD.Vector(0, 0, 1))
for data, z in zip(poles_data, terrain_z):
data['terrain_z'] = z.z
except Exception as e:
FreeCAD.Console.PrintError(f"Terrain projection failed: {str(e)}\n")
for data in poles_data:
data['terrain_z'] = 0
# Write data to sheet
row = 3
group_from = row
f = poles_data[0]['frame']
for i, data in enumerate(poles_data):
if f != data["frame"]:
style_range(sheet, 'A' + str(group_from) + ':F' + str(row - 1),
border=Border(top=thin, left=thin, right=thin, bottom=thin),
font=Font(name='Quicksand', size=11, ),
alignment=Alignment(horizontal="center", vertical="center"))
sheet.merge_cells('A' + str(group_from) + ':A' + str(row - 1))
style_range(sheet, 'A' + str(group_from) + ':A' + str(row - 1),
border=Border(top=thin, left=thin, right=thin, bottom=thin),
font=Font(name='Quicksand', size=11, ),
alignment=Alignment(horizontal="center", vertical="center"))
# sheet['A{0}'.format(row)] = ""
sheet.row_dimensions[row].height = 5
row += 1
f = data["frame"]
group_from = row
sheet['A{0}'.format(row)] = data['frame']
sheet['B{0}'.format(row)] = data['number']
sheet['C{0}'.format(row)] = data['type']
sheet['D{0}'.format(row)] = round(data['center'].x, 0) * scale
sheet['D{0}'.format(row)].number_format = "0.000"
sheet['E{0}'.format(row)] = round(data['center'].y, 0) * scale
sheet['E{0}'.format(row)].number_format = "0.000"
try:
sheet['F{0}'.format(row)] = round(data['terrain_z'].z, 0) * scale
sheet['F{0}'.format(row)].number_format = "0.000"
except:
pass
sheet['G{0}'.format(row)] = round(data['head_z']) * scale
sheet['G{0}'.format(row)].number_format = "0.000"
sheet['H{0}'.format(row)] = data["length"] * scale
sheet['H{0}'.format(row)].number_format = "0.000"
sheet['I{0}'.format(row)] = '=G{0}-F{0}'.format(row)
sheet['I{0}'.format(row)].number_format = "0.000"
sheet['J{0}'.format(row)] = '=H{0}-I{0}'.format(row)
sheet['J{0}'.format(row)].number_format = "0.000"
style_range(sheet, 'A' + str(row) + ':J' + str(row),
border=Border(top=thin, left=thin, right=thin, bottom=thin),
font=Font(name='Quicksand', size=11, ),
alignment=Alignment(horizontal="center", vertical="center"))
row += 1
def spreadsheetBOQPanelCollision(sheet, sel):
# Headers:
sheet['A1'] = 'Frame'
sheet['B1'] = 'Nombre'
sheet['C1'] = 'X'
sheet['D1'] = 'Y'
sheet['E1'] = 'Z'
sheet['G1'] = 'Ángulo E-O'
sheet['H1'] = 'Nº Hincas'
sheet.column_dimensions['A'].width = 30
sheet.column_dimensions['B'].width = 8
sheet.column_dimensions['C'].width = 20
sheet.column_dimensions['D'].width = 20
sheet.column_dimensions['E'].width = 20
sheet.column_dimensions['F'].width = 20
sheet.column_dimensions['G'].width = 20
sheet.column_dimensions['H'].width = 20
sheet.column_dimensions['I'].width = 20
sheet.row_dimensions[1].height = 40
style_range(sheet, 'A1:I1',
border=Border(top=thin, left=thin, right=thin, bottom=thin),
fill=PatternFill("solid", fgColor="7DA4B8"),
font=Font(name='Quicksand', size=11, b=True, color="FFFFFF"),
alignment=Alignment(horizontal="center", vertical="center"))
sheet['A2'] = ""
sheet.row_dimensions[2].height = 5
# Data:
for frame_ind in range(0, len(sel)):
frame = sel[frame_ind]
sheet['A{0}'.format(ind + 2)] = ind
sheet['B{0}'.format(ind + 2)] = sel[ind].Label
sheet['C{0}'.format(ind + 2)] = sel[ind].Placement.Base.x * scale
sheet['D{0}'.format(ind + 2)] = sel[ind].Placement.Base.y * scale
sheet['E{0}'.format(ind + 2)] = sel[ind].Placement.Base.z * scale
sheet['F{0}'.format(ind + 2)] = sel[ind].Placement.Rotation.toEuler()[0]
sheet['G{0}'.format(ind + 2)] = sel[ind].Placement.Rotation.toEuler()[1]
sheet['H{0}'.format(ind + 2)] = sel[ind].NumberPole.Value
class CommandBOQMechanical:
def GetResources(self):
return {'Pixmap': str(os.path.join(PVPlantResources.DirIcons, "boqm.svg")),
'Accel': "R, M",
'MenuText': QT_TRANSLATE_NOOP("Placement", "BOQ Mecánico"),
'ToolTip': QT_TRANSLATE_NOOP("Placement", "Calcular el BOQ de la")}
def Activated(self):
doc = FreeCAD.ActiveDocument
if not doc or not doc.FileName:
FreeCAD.Console.PrintError("Document must be saved first\n")
return
try:
sel = [obj for obj in FreeCAD.ActiveDocument.Objects if (hasattr(obj, "Proxy") and (obj.Proxy.Type == "Tracker"))]
if not sel:
FreeCAD.Console.PrintWarning("No valid trackers found\n")
return
path = os.path.dirname(doc.FileName)
filename = os.path.join(path, "BOQ_Mechanical.xlsx")
sel = sorted(sel, key=lambda x: x.Label)
wb = openpyxl.Workbook()
wb.remove(wb.active) # Remove default sheet
frames_sheet = wb.create_sheet("Frames Information")
spreadsheetBOQFrames(frames_sheet, sel)
poles_sheet = wb.create_sheet("Poles information")
spreadsheetBOQPoles(poles_sheet , sel)
wb.save(filename)
FreeCAD.Console.PrintMessage(f"Report generated: {filename}\n")
open_file(path)
except Exception as e:
FreeCAD.Console.PrintError(f"Error generating BOQ: {str(e)}\n")
def IsActive(self):
if FreeCAD.ActiveDocument:
return True
else:
return False