Files
PVPlant/Export/PVPlantBOQMechanical.py

361 lines
14 KiB
Python
Raw Normal View History

2025-01-28 00:04:13 +01:00
# /**********************************************************************
# * *
# * 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 *
# * *
# ***********************************************************************
2025-05-08 08:29:11 +02:00
import os
import platform
import subprocess
from typing import Dict, List
2025-01-28 00:04:13 +01:00
import FreeCAD
2025-05-08 08:29:11 +02:00
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
2025-01-28 00:04:13 +01:00
if FreeCAD.GuiUp:
from PySide.QtCore import QT_TRANSLATE_NOOP
2025-05-08 08:29:11 +02:00
__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")
2025-01-28 00:04:13 +01:00
# 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
2025-05-08 08:29:11 +02:00
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])
2025-01-28 00:04:13 +01:00
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
2025-05-08 08:29:11 +02:00
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
2025-01-28 00:04:13 +01:00
import MeshPart as mp
from Mechanical.Frame import PVPlantFrame
# Data:
terrain = PVPlantSite.get().Terrain.Mesh # Shape
2025-05-08 08:29:11 +02:00
poles_data = []
2025-01-28 00:04:13 +01:00
2025-05-08 08:29:11 +02:00
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
2025-01-28 00:04:13 +01:00
row = 3
group_from = row
2025-05-08 08:29:11 +02:00
print(poles_data[0])
f = poles_data[0]['frame']
for i, data in enumerate(poles_data):
if f != data["frame"]:
2025-01-28 00:04:13 +01:00
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"))
2025-05-08 08:29:11 +02:00
# sheet['A{0}'.format(row)] = ""
2025-01-28 00:04:13 +01:00
sheet.row_dimensions[row].height = 5
row += 1
2025-05-08 08:29:11 +02:00
f = data["frame"]
2025-01-28 00:04:13 +01:00
group_from = row
2025-05-08 08:29:11 +02:00
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
2025-01-28 00:04:13 +01:00
sheet['D{0}'.format(row)].number_format = "0.000"
2025-05-08 08:29:11 +02:00
sheet['E{0}'.format(row)] = round(data['center'].y, 0) * scale
2025-01-28 00:04:13 +01:00
sheet['E{0}'.format(row)].number_format = "0.000"
try:
2025-05-08 08:29:11 +02:00
sheet['F{0}'.format(row)] = round(data['terrain_z'].z, 0) * scale
2025-01-28 00:04:13 +01:00
sheet['F{0}'.format(row)].number_format = "0.000"
except:
pass
2025-05-08 08:29:11 +02:00
sheet['G{0}'.format(row)] = round(data['head_z']) * scale
2025-01-28 00:04:13 +01:00
sheet['G{0}'.format(row)].number_format = "0.000"
2025-05-08 08:29:11 +02:00
sheet['H{0}'.format(row)] = data["length"] * scale
2025-01-28 00:04:13 +01:00
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),
2025-05-08 08:29:11 +02:00
font=Font(name='Quicksand', size=11, ),
2025-01-28 00:04:13 +01:00
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
2025-03-28 19:40:11 +06:00
class CommandBOQMechanical:
2025-01-28 00:04:13 +01:00
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):
2025-05-08 08:29:11 +02:00
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")
2025-01-28 00:04:13 +01:00
def IsActive(self):
if FreeCAD.ActiveDocument:
return True
else:
return False