Skip to content

Commit

Permalink
Add spreadsheet generation for templates (#260)
Browse files Browse the repository at this point in the history
* add generate_spreadsheet method to templates;

* add test for spreadsheet generation from templates

* Add csv_generation; adjust csv/template generation to emit buffer or
write to file

* Parameterize tests, add testing of inmem + file based

Move template generation tests to their own file

* setup tables during template generation test

* use fixtures properly to avoid shared state

* remove unneeded inline arg from generate_ functions
  • Loading branch information
gtfierro authored Jun 20, 2023
1 parent 7e446a9 commit 82413c6
Show file tree
Hide file tree
Showing 2 changed files with 250 additions and 0 deletions.
88 changes: 88 additions & 0 deletions buildingmotif/dataclasses/template.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import csv
import logging
import warnings
from collections import Counter
from copy import copy
from dataclasses import dataclass
from io import BytesIO, StringIO
from itertools import chain
from os import PathLike
from secrets import token_hex
from typing import TYPE_CHECKING, Dict, Generator, List, Optional, Set, Tuple, Union

Expand Down Expand Up @@ -440,6 +445,89 @@ def find_subgraphs(
for mapping, sg in matcher.building_mapping_subgraphs_iter():
yield mapping, sg, matcher.remaining_template(mapping)

def generate_csv(self, path: Optional[PathLike] = None) -> Optional[StringIO]:
"""
Generate a CSV for this template which contains a column for each template parameter.
Once filled out, the resulting CSV file can be passed to a Template Ingress to populate a model.
Returns a 'io.BytesIO' object which can be written to a file or sent to another program/function.
:param path: if not None, writes the CSV to the indicated file
:type path: PathLike, optional
:return: String buffer containing the resulting CSV file
:rtype: StringIO
"""
all_parameters = copy(self.parameters)
mandatory_parameters = all_parameters - set(self.optional_args)
row_data = list(mandatory_parameters) + list(self.optional_args)

if path is not None:
# write directly to file
with open(path, "w") as f:
writer = csv.writer(f)
writer.writerow(row_data)
return None

# write to in-memory file
output = StringIO()
writer = csv.writer(output)
writer.writerow(row_data)
return output

def generate_spreadsheet(
self, path: Optional[PathLike] = None
) -> Optional[BytesIO]:
"""
Generate a spreadsheet for this template which contains a column for each template parameter.
Once filled out, the resulting spreadsheet can be passed to a Template Ingress to populate a model.
Returns a 'io.BytesIO' object which can be written to a file or sent to another program/function.
:param path: if not None, writes the CSV to the indicated file
:type path: PathLike, optional
:return: Byte buffer containing the resulting spreadsheet file
:rtype: BytesIO
"""
try:
from openpyxl import Workbook
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.table import Table, TableStyleInfo
except ImportError:
logging.critical(
"Install the 'xlsx-ingress' module, e.g. 'pip install buildingmotif[xlsx-ingress]'"
)
return None
all_parameters = copy(self.parameters)
mandatory_parameters = all_parameters - set(self.optional_args)

workbook = Workbook()
sheet = workbook.active
if sheet is None:
raise Exception("Could not open active sheet in Workbook")

row_data = list(mandatory_parameters) + list(self.optional_args)
for column_index, cell_value in enumerate(row_data, 1):
column_letter = get_column_letter(column_index)
sheet[f"{column_letter}1"] = cell_value # type: ignore
# Adjust column width based on cell content
column_dimensions = sheet.column_dimensions[column_letter] # type: ignore
column_dimensions.width = max(column_dimensions.width, len(str(cell_value)))

tab = Table(
displayName="Table1", ref=f"A1:{get_column_letter(len(row_data))}10"
)
style = TableStyleInfo(name="TableStyleMedium9", showRowStripes=True)
tab.tableStyleInfo = style
sheet.add_table(tab)

if path is not None:
# write directly to file
workbook.save(path)
return None

# save the file in-memory and return the resulting buffer
f = BytesIO()
workbook.save(f)
return f


@dataclass
class Dependency:
Expand Down
162 changes: 162 additions & 0 deletions tests/unit/dataclasses/test_template_generation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import csv
from io import StringIO
from pathlib import Path
from tempfile import NamedTemporaryFile

import openpyxl
from rdflib import Namespace
from rdflib.compare import isomorphic

from buildingmotif.dataclasses import Library
from buildingmotif.ingresses.csv import CSVIngress
from buildingmotif.ingresses.template import TemplateIngress
from buildingmotif.ingresses.xlsx import XLSXIngress

BLDG = Namespace("urn:bldg/")


# utility function for spreadsheet tests
def _add_spreadsheet_row(sheet, bindings):
num_columns = sheet.max_column
for column in range(1, num_columns + 1):
param = sheet.cell(row=1, column=column).value
sheet.cell(row=2, column=column).value = bindings[param][len(BLDG) :]


def _add_csv_row(params, tempfile, bindings):
params = [param.strip() for param in params]
if isinstance(tempfile, StringIO):
w = csv.writer(tempfile)
w.writerow([bindings[param][len(BLDG) :] for param in params])
tempfile.flush()
else:
with open(Path(tempfile.name), "a") as f:
w = csv.writer(f)
w.writerow([bindings[param][len(BLDG) :] for param in params])


def pytest_generate_tests(metafunc):
if metafunc.fixturenames == [
"clean_building_motif",
"template_name",
"include_optional",
"inline_dependencies",
]:
test_cases = {
"NOdep-NOoptional-NOinline": ("supply-fan", False, False),
"NOdep-WITHoptional-NOinline": ("outside-air-damper", True, False),
"WITHdep-WITHoptional-NOinline": ("single-zone-vav-ahu", True, False),
"WITHdep-WITHoptional-WITHinline": ("single-zone-vav-ahu", True, True),
}
metafunc.parametrize(
"template_name,include_optional,inline_dependencies",
test_cases.values(),
ids=test_cases.keys(),
)


def test_template_generation_inmemory(
clean_building_motif, template_name, include_optional, inline_dependencies
):
fixture_lib = Library.load(directory="tests/unit/fixtures/templates")
template = fixture_lib.get_template_by_name(template_name)
template = fixture_lib.get_template_by_name(template_name)
if inline_dependencies:
template = template.inline_dependencies()
bindings, filled = template.fill(BLDG, include_optional=include_optional)

with NamedTemporaryFile(suffix=".xlsx") as dest:
output = template.generate_spreadsheet()
assert output is not None
dest.write(output.getbuffer())

w = openpyxl.load_workbook(dest.name)
_add_spreadsheet_row(w.active, bindings)
w.save(Path(dest.name))

reader = XLSXIngress(Path(dest.name))
ing = TemplateIngress(template, None, reader)
g = ing.graph(BLDG)

assert isomorphic(
g, filled
), f"Template -> spreadsheet -> ingress -> graph path did not generate a result isomorphic to just filling the template {template.name}"


def test_template_generation_file(
clean_building_motif, template_name, include_optional, inline_dependencies
):
fixture_lib = Library.load(directory="tests/unit/fixtures/templates")
template = fixture_lib.get_template_by_name(template_name)
if inline_dependencies:
template = template.inline_dependencies()
bindings, filled = template.fill(BLDG, include_optional=include_optional)

with NamedTemporaryFile(suffix=".xlsx") as dest:
output = template.generate_spreadsheet(Path(dest.name))
assert output is None

w = openpyxl.load_workbook(dest.name)
_add_spreadsheet_row(w.active, bindings)
w.save(Path(dest.name))

reader = XLSXIngress(Path(dest.name))
ing = TemplateIngress(template, None, reader)
g = ing.graph(BLDG)

assert isomorphic(
g, filled
), f"Template -> spreadsheet -> ingress -> graph path did not generate a result isomorphic to just filling the template {template.name}"


def test_csv_generation_inmemory(
clean_building_motif, template_name, include_optional, inline_dependencies
):
fixture_lib = Library.load(directory="tests/unit/fixtures/templates")
template = fixture_lib.get_template_by_name(template_name)
if inline_dependencies:
template = template.inline_dependencies()
bindings, filled = template.fill(BLDG, include_optional=include_optional)

with NamedTemporaryFile(mode="w", suffix=".csv") as dest:
output = template.generate_csv()
assert output is not None
dest.writelines([output.getvalue()])
dest.flush()

params = output.getvalue().split(",")
_add_csv_row(params, dest, bindings)

reader = CSVIngress(Path(dest.name))
ing = TemplateIngress(template, None, reader)
g = ing.graph(BLDG)

assert isomorphic(
g, filled
), f"Template -> csv -> ingress -> graph path did not generate a result isomorphic to just filling the template {template.name}\n{(filled - g).serialize()}"


def test_csv_generation_file(
clean_building_motif, template_name, include_optional, inline_dependencies
):
fixture_lib = Library.load(directory="tests/unit/fixtures/templates")
template = fixture_lib.get_template_by_name(template_name)
if inline_dependencies:
template = template.inline_dependencies()
bindings, filled = template.fill(BLDG, include_optional=include_optional)

with NamedTemporaryFile(mode="w", suffix=".csv") as dest:
output = template.generate_csv(Path(dest.name))
assert output is None

with open(Path(dest.name)) as f:
params = f.read().strip().split(",")
_add_csv_row(params, dest, bindings)

reader = CSVIngress(Path(dest.name))
ing = TemplateIngress(template, None, reader)
g = ing.graph(BLDG)

assert isomorphic(
g, filled
), f"Template -> csv -> ingress -> graph path did not generate a result isomorphic to just filling the template {template.name}\n{(filled - g).serialize()}"

0 comments on commit 82413c6

Please sign in to comment.