ifc5d - continue refactor

This commit is contained in:
Andrej730
2025-04-18 10:52:24 +05:00
parent ff90396bcf
commit 1e30eae1d7
3 changed files with 165 additions and 133 deletions
+37 -11
View File
@@ -23,6 +23,14 @@ Planned (would you like to contribute? Please reach out!):
## Usage CSV to IFC
Simple example:
```python
import ifc5d.csv2ifc
csv2ifc = ifc5d.csv2ifc.Csv2Ifc(csv_filepath, ifc_file)
csv2ifc.execute()
```
See example files as a CSV file format reference:
- `sample_cost_schedule_house_FR.csv` / `.ods`
- `schedule.csv`, `rates.csv` (schedule of rates example)
@@ -31,21 +39,39 @@ Some notes on the format:
- Empty lines are ignored.
- Importing ods/xlsx is not currently supported, only csv.
- 'Property', 'Query' columns are required only for non-schedule of rates cost schedules.
- Some older exports may have 'Description' field instead of 'Name', it is safe to just rename it.
- 'Index' was preferred for import hierarchy source over 'Hierarchy' as it's easier to edit from the table view.
- In non-SoR if cost values are not provided in any way and cost item has subitems, then `SUM()` will added automatically as it's cost value.
Mixing `SUM()` cost items and items with their own value is not supported.
## Columns Description
**General Columns**
- 'Index' - is a hierarchy depth that's used for building hierarchy during csv import. Starts from 1.
E.g. root items of the same level have index '1', their children have '2', etc.
- 'Name' - IfcCostItem.Name.
Some older exports may have 'Description' field instead of 'Name', it is safe to just rename it.
- 'Identification' - IfcCostItem.Identification.
- 'Unit' - IfcCostValue unit, should be provided as a unit symbol.
E.g. 'm2', 'm3', 'kg', etc.
- 'Value' - overall cost value to assign to IfcCostItem.
- All other columns that are not mentioned in this list or the one below will be interpeted as cost value categories.
Note that if 'Value' is provided, it takes priority over subcategories.
Non-schedule of rates columns:
- 'Quantity' - total cost item quantity.
- 'Property' - quantity name that should be added to IfcCostItem from 'Query' elements.
If 'Query' is provided, 'Property' can be left empty or set to "COUNT" to count queried elements.
- 'Query' - selector query for elements to assign to IfcCostItem.
If query is provided, it takes priority over 'Quantity' (using both is not supported).
**Exported informational columns (not used for import)**
- 'Hierarchy' is just an informational column that doesn't affect the import.
E.g. '1', '1.1', '1.1.1', etc.
- 'Index' column is a hierarchy depth that's used for building hierarchy during csv import, starting from 1.
E.g. root items of the same level have index '1', their children have '2', etc.
- 'Index' was preferred for import hierarchy source over 'Hierarchy' as it's easier to edit from the table view.
- 'Id' - IfcCostItem.id
- 'RateSubtotal' - all IfcCostItem specific costs, not including subitem costs.
- 'TotalPrice' - IfcCostItem total cost, including subitem sum calculations.
Simple example:
```python
import ifc5d.csv2ifc
csv2ifc = ifc5d.csv2ifc.Csv2Ifc(csv_filepath, ifc_file)
csv2ifc.execute()
```
## Usage IFC to CSV, ODS, XSLS
+31 -32
View File
@@ -32,27 +32,30 @@ from typing import Any, Union, Optional, TypedDict, NotRequired
class CsvHeader(TypedDict):
Index: int
Name: int
Quantity: int
Unit: int
Identification: NotRequired[int]
Value: NotRequired[int]
# Not schedule of rates:
Property: int
Query: int
Quantity: NotRequired[int]
Property: NotRequired[int]
Query: NotRequired[int]
class CostItem(TypedDict):
children: list[CostItem]
assignments: dict[str, Any]
ifc: NotRequired[ifcopenshell.entity_instance]
Identification: Union[str, None]
Name: Union[str, None]
Unit: Union[str, None]
Quantity: Union[float, None]
CostValues: Union[dict[str, float], float, None]
# Only might be available in non-SOR.
Quantity: Union[float, None]
Property: Union[str, None]
Query: Union[str, None]
class Csv2Ifc:
# Inputs.
@@ -118,12 +121,9 @@ class Csv2Ifc:
# parse header
if not self.headers:
self.has_categories = True
for i, col in enumerate(row):
if not col:
continue
if col == "Value":
self.has_categories = False
self.headers[col] = i
self.headers = {col: i for i, col in enumerate(row) if col}
if "Value" in self.headers:
self.has_categories = False
# validate header
mandatory_fields = {"Name", "Quantity", "Unit"}
@@ -169,18 +169,14 @@ class Csv2Ifc:
def get_row_cost_data(self, row: list[str]) -> CostItem:
name = row[self.headers["Name"]]
identification = row[self.headers["Identification"]] if "Identification" in self.headers else None
quantity = row[self.headers["Quantity"]]
quantity = row[(self.headers["Quantity"])] if "Quantity" in self.headers else None
unit = row[self.headers["Unit"]]
if not self.is_schedule_of_rates:
assignments = {
"PropertyName": row[self.headers["Property"]],
"Query": row[self.headers["Query"]],
}
if self.is_schedule_of_rates:
property_name, query = None, None
else:
assignments = {
"PropertyName": None,
"Query": None,
}
property_name = row[(self.headers["Property"])] if "Property" in self.headers else None
query = row[(self.headers["Query"])] if "Query" in self.headers else None
if self.has_categories:
cost_values = {
k: locale.atof(row[v])
@@ -195,10 +191,11 @@ class Csv2Ifc:
return {
"Identification": str(identification) if identification else None,
"Name": str(name) if name else None,
"Quantity": float(quantity) if quantity else None,
"Unit": str(unit) if unit else None,
"CostValues": cost_values,
"assignments": assignments,
"Quantity": float(quantity) if quantity else None,
"Property": property_name,
"Query": query,
"children": [],
}
@@ -229,13 +226,16 @@ class Csv2Ifc:
cost_item["ifc"].Name = cost_item["Name"]
cost_item["ifc"].Identification = cost_item["Identification"]
if not cost_item["CostValues"] and cost_item["children"]:
cost_values = cost_item["CostValues"]
if ((isinstance(cost_values, dict) and len(cost_values) == 0) or (cost_values is None)) and cost_item[
"children"
]:
if not self.is_schedule_of_rates:
cost_value = ifcopenshell.api.cost.add_cost_value(self.file, parent=cost_item["ifc"])
cost_value.Category = "*"
elif self.has_categories:
assert isinstance(cost_item["CostValues"], dict)
for category, value in cost_item["CostValues"].items():
assert isinstance(cost_values, dict)
for category, value in cost_values.items():
cost_value = ifcopenshell.api.cost.add_cost_value(self.file, parent=cost_item["ifc"])
cost_value.AppliedValue = self.file.createIfcMonetaryMeasure(value)
if category != "Rate" or category != "Price":
@@ -243,7 +243,7 @@ class Csv2Ifc:
category = category.replace("Rate", "")
category = category.strip()
cost_value.Category = category
elif cost_item["CostValues"]:
elif cost_values:
cost_value = ifcopenshell.api.cost.add_cost_value(self.file, parent=cost_item["ifc"])
cost_value.AppliedValue = self.file.createIfcMonetaryMeasure(cost_item["CostValues"])
if self.is_schedule_of_rates:
@@ -267,10 +267,9 @@ class Csv2Ifc:
quantity = None
quantity_class = ifcopenshell.util.unit.get_symbol_quantity_class(cost_item["Unit"])
if not cost_item["assignments"]["PropertyName"] or cost_item["assignments"]["PropertyName"].upper() == "COUNT":
prop_name = cost_item["Property"]
if not prop_name or prop_name.upper() == "COUNT":
prop_name = ""
else:
prop_name = cost_item["assignments"]["PropertyName"]
if not self.is_schedule_of_rates and cost_item["Quantity"] is not None:
quantity = ifcopenshell.api.cost.add_cost_item_quantity(
@@ -281,8 +280,8 @@ class Csv2Ifc:
if prop_name:
quantity.Name = prop_name
if cost_item["assignments"]["Query"]:
results = ifcopenshell.util.selector.filter_elements(self.file, cost_item["assignments"]["Query"])
if cost_item["Query"]:
results = ifcopenshell.util.selector.filter_elements(self.file, cost_item["Query"])
results = [r for r in results if has_property(self.file, r, prop_name)]
# NOTE: currently we do not support count quantities that have
# both defined quantity in .csv "Quantity" column
+97 -90
View File
@@ -17,6 +17,7 @@
# along with Ifc5D. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import os
import time
import argparse
@@ -31,17 +32,32 @@ from typing import Union, Optional, Any, TypedDict, NotRequired
class CostItem(TypedDict):
# Exported columns.
Index: int
Hierarchy: str
Id: int
Identification: Union[str, None]
Name: Union[str, None]
Unit: str
Quantity: int
ChildrenData: list["CostItem"]
# Total Price: float
# * Cost: str
# Rate Subtotal: float
Quantity: Union[float, None]
RateSubtotal: float
TotalPrice: float
# Internal.
cost_categories: dict[str, float]
class CostItemQuantity(TypedDict):
quantity: Union[float, None]
class CostValue(TypedDict):
id: int
"""Cost Value id."""
name: Union[str, None]
applied_value: float
unit: str
category: str
class IfcDataGetter:
@@ -64,10 +80,10 @@ class IfcDataGetter:
return ifcopenshell.util.cost.get_root_cost_items(cost_schedule)
@staticmethod
def get_cost_item_values(cost_item: Union[ifcopenshell.entity_instance, None]) -> Union[list[dict[str, Any]], None]:
def get_cost_item_values(cost_item: Union[ifcopenshell.entity_instance, None]) -> Union[list[CostValue], None]:
if not cost_item:
return None
values = []
values: list[CostValue] = []
for cost_value in cost_item.CostValues or []:
name = cost_value.Name
applied_value = ifcopenshell.util.cost.calculate_applied_value(cost_item, cost_value)
@@ -84,48 +100,33 @@ class IfcDataGetter:
return values
@staticmethod
def process_categories(cost_item: ifcopenshell.entity_instance, categories: set[str]) -> set[str]:
"""
:param categories: A set to fill with categories.
"""
for cost_value in cost_item.CostValues or []:
if cost_value.Category:
categories.add("{}{}".format(cost_value.Category, " Cost"))
return categories
@staticmethod
def process_cost_item_categories(cost_item: ifcopenshell.entity_instance, categories: set[str]) -> set[str]:
IfcDataGetter.process_categories(cost_item, categories)
for child in ifcopenshell.util.cost.get_nested_cost_items(cost_item):
IfcDataGetter.process_cost_item_categories(child, categories)
return categories
@staticmethod
def get_cost_rates_categories(schedule: ifcopenshell.entity_instance) -> set[str]:
"""
:param categories: A set to fill with categories.
"""
categories: set[str] = set()
for cost_item in IfcDataGetter.get_root_costs(schedule):
IfcDataGetter.process_cost_item_categories(cost_item, categories)
return categories
@staticmethod
def process_cost_data(
def get_cost_items_data(
file: ifcopenshell.file,
cost_item: ifcopenshell.entity_instance,
cost_items_data: list[CostItem],
index: int = 1,
hierarchy: str = "1",
) -> None:
) -> list[CostItem]:
"""
:param cost_items_data: A list to fill with cost items.
:param index: Current hierarchy depth.
"""
cost_items_data: list[CostItem] = []
quantity_data = IfcDataGetter.get_cost_item_quantity(file, cost_item)
cost_values_data = IfcDataGetter.get_cost_item_values(cost_item)
rate_subtotal = 0.0
total_price = 0.0
cost_categories: dict[str, float] = {}
for cost_value in cost_values_data or []:
category = cost_value["category"]
if cost_value["category"] == "*": # A sum.
total_price = cost_value["applied_value"]
else:
cost_category = "{}{}".format(category, " Cost")
cost_categories[cost_category] = cost_value["applied_value"]
rate_subtotal += cost_value["applied_value"]
data: CostItem = {
"Index": index,
"Hierarchy": hierarchy,
@@ -133,35 +134,25 @@ class IfcDataGetter:
"Identification": cost_item.Identification,
"Name": cost_item.Name,
"Unit": cost_values_data[0]["unit"] if cost_values_data else "",
"Quantity": quantity_data["quantity"]["total_quantity"],
"ChildrenData": [],
"Quantity": quantity_data["quantity"],
"RateSubtotal": rate_subtotal,
"TotalPrice": total_price,
"cost_categories": cost_categories,
}
for cost_value in cost_values_data or []:
cost_category = "{}{}".format(cost_value["category"], " Cost")
data[cost_category] = cost_value["applied_value"]
if data.get("* Cost", None):
data["Total Price"] = data["* Cost"]
data["* Cost"] = ""
rate_subtotal = 0
for key, value in data.items():
if "Cost" in key and not "*" in key:
rate_subtotal += value
data["Rate Subtotal"] = rate_subtotal
cost_items_data.append(data)
index += 1
child_hierarchy = hierarchy + ".1"
for i, nested_cost in enumerate(ifcopenshell.util.cost.get_nested_cost_items(cost_item), 1):
child_hierarchy = f"{hierarchy}.{i}"
IfcDataGetter.process_cost_data(file, nested_cost, cost_items_data, index, child_hierarchy)
cost_items_data.extend(IfcDataGetter.get_cost_items_data(file, nested_cost, index, child_hierarchy))
return cost_items_data
@staticmethod
def get_cost_items_data(file: ifcopenshell.file, schedule: ifcopenshell.entity_instance) -> list[CostItem]:
def get_schedule_cost_items_data(file: ifcopenshell.file, schedule: ifcopenshell.entity_instance) -> list[CostItem]:
cost_items_data: list[CostItem] = []
for cost_item in IfcDataGetter.get_root_costs(schedule):
IfcDataGetter.process_cost_data(file, cost_item, cost_items_data)
cost_items_data.extend(IfcDataGetter.get_cost_items_data(file, cost_item))
return cost_items_data
@staticmethod
@@ -184,40 +175,45 @@ class IfcDataGetter:
return IfcDataGetter.format_unit(unit.UnitComponent)
@staticmethod
def get_cost_item_quantity(file: ifcopenshell.file, cost_item: ifcopenshell.entity_instance) -> dict[str, Any]:
def get_cost_item_quantity(file: ifcopenshell.file, cost_item: ifcopenshell.entity_instance) -> CostItemQuantity:
accounted_for: set[ifcopenshell.entity_instance] = set()
# NOTE: take_off_name is not used anywhere.
take_off_name: str = ""
# TODO: handle multiple quantities, THOSE WHHICH ARE JUYST ASSIGNED TO THE COST ITEM DIRECTLY, NOT THROUGH OBJECTS.
def add_quantity(quantity: ifcopenshell.entity_instance, take_off_name: str) -> float:
accounted_for.append(quantity)
accounted_for.add(quantity)
if take_off_name == "":
# 0 IfcPhysicalSimpleQuantity.Name
take_off_name = quantity[0]
if quantity[0] != take_off_name:
take_off_name = "mixed-takeoff-quantities"
if quantity[0] != take_off_name:
take_off_name = "mixed-takeoff-quantities"
# 3 IfcPhysicalSimpleQuantity.Value
return quantity[3]
take_off_name = ""
total_cost_quantity = 0
accounted_for = []
cost_item_quantities = cost_item.CostQuantities
cost_item_quantities: list[ifcopenshell.entity_instance] = cost_item.CostQuantities
if cost_item_quantities:
for rel in cost_item.Controls or []:
for related_object in rel.RelatedObjects:
qtos = ifcopenshell.util.element.get_psets(related_object, qtos_only=True)
for quantities in qtos.values() or []:
qto = file.by_id(quantities["id"])
for quantity in qto.Quantities:
if quantity not in cost_item_quantities:
continue
total_cost_quantity += add_quantity(quantity, take_off_name)
accounted_for.append(quantity)
total_cost_quantity = 0.0
# Add quantities from cost assignments.
for related_object in ifcopenshell.util.cost.get_cost_assignments_by_type(cost_item):
qtos = ifcopenshell.util.element.get_psets(related_object, qtos_only=True)
for quantities in qtos.values() or []:
qto = file.by_id(quantities["id"])
for quantity in qto.Quantities:
if quantity not in cost_item_quantities:
continue
total_cost_quantity += add_quantity(quantity, take_off_name)
# Add cost item quantities assigned to the cost item directly.
for quantity in cost_item_quantities:
if not quantity in accounted_for:
if quantity not in accounted_for:
total_cost_quantity += add_quantity(quantity, take_off_name)
else:
total_cost_quantity = None
return {
"id": cost_item.id(),
"name": cost_item.Name,
"quantity": {"take_off_name": take_off_name, "total_quantity": total_cost_quantity},
"quantity": total_cost_quantity,
}
@@ -282,31 +278,42 @@ class Ifc5Dwriter:
counter: Counter[str] = Counter()
for cost_schedule in self.cost_schedules:
sheet_id = cost_schedule.id()
self.sheet_data[sheet_id] = sheet_data = SheetData()
sheet_data["headers"] = [
cost_items = IfcDataGetter.get_schedule_cost_items_data(self.file, cost_schedule)
headers: list[str] = [
"Id",
"Hierarchy",
"Index",
"Identification",
"Name",
"Quantity",
"Unit",
]
cost_rate_categories = IfcDataGetter.get_cost_rates_categories(cost_schedule)
sheet_data["headers"].extend(list(cost_rate_categories))
sheet_data["headers"].extend(["Rate Subtotal", "Total Price", "Children"])
sheet_data["cost_items"] = IfcDataGetter.get_cost_items_data(self.file, cost_schedule)
sheet_data["UpdateDate"] = IfcDataGetter.canonicalise_time(
ifcopenshell.util.date.ifc2datetime(cost_schedule.UpdateDate)
)
sheet_data["PredefinedType"] = cost_schedule.PredefinedType
schedule_name = cost_schedule.Name or "Unnamed"
if cost_schedule.PredefinedType != "SCHEDULEOFRATES":
headers.insert(-1, "Quantity")
headers.extend(["RateSubtotal", "TotalPrice"])
# Handle cost categories.
categories: set[str] = set()
for cost_item in cost_items:
for category, value in cost_item["cost_categories"].items():
categories.add(category)
cost_item[category] = value
assert not (intersection := categories.intersection(headers)), intersection
headers.extend(categories)
schedule_name = cost_schedule.Name or "Unnamed"
counter[schedule_name] += 1
if (count := counter[schedule_name]) > 1:
schedule_name = f"{schedule_name}_{count - 1}"
sheet_data["Name"] = schedule_name
self.sheet_data[sheet_id] = {
"Name": schedule_name,
"headers": headers,
"cost_items": cost_items,
"UpdateDate": IfcDataGetter.canonicalise_time(
ifcopenshell.util.date.ifc2datetime(cost_schedule.UpdateDate)
),
"PredefinedType": cost_schedule.PredefinedType,
}
def multiply_cells(self, cell1, cell2):
return "={}*{}".format(cell1, cell2)