"""Write an idm file from a HBJSON file."""
import math
import pathlib
import shutil
from typing import List, Tuple
from ladybug_geometry.bounding import bounding_box
from ladybug_geometry.geometry3d import Vector3D, Point3D, Plane, Face3D
from honeybee.model import Model, Room
from honeybee.facetype import RoofCeiling, Wall, Floor, AirBoundary, get_type_from_normal
from .archive import zip_folder_to_idm
from .bldgbody import section_to_idm, MAX_FLOOR_ELEVATION_DIFFERENCE, \
IDA_ICE_BUILDING_BODY_TOL
from .shade import shades_to_idm, shade_meshes_to_idm
from .face import face_to_idm, opening_to_idm, face_reference_plane
[docs]
def ceilings_to_idm(
room: Room, origin: Point3D, tolerance: float, angle_tolerance: float = 1.0,
decimal_places: int = 3
):
"""Translate the ceilings of a Room to an IDM ENCLOSING-ELEMENT.
Args:
room: A honeybee Room.
origin: A Point3D for the origin of the parent Room.
tolerance: The minimum difference between x, y, and z coordinate
values at which points are considered distinct.
angle_tolerance: The max angle in degrees that Face normal can differ
from the World Z before the Face is treated as being in the
World XY plane. (Default: 1).
decimal_places: An integer for the number of decimal places to which
coordinate values will be rounded. (Default: 3).
"""
index = -1000
# if there's only one ceiling, just translate it
faces = room.roof_ceilings
if len(faces) == 0:
return ''
if len(faces) == 1:
return face_to_idm(faces[0], origin, index, angle_tolerance, decimal_places)
# check to see the vertical range across the ceilings
min_pt, max_pt = bounding_box([face.geometry for face in faces])
if max_pt.z - min_pt.z <= tolerance:
# all the ceilings are the same height
return '\n'.join(
face_to_idm(face, origin, index, angle_tolerance, decimal_places)
for face in faces
)
# get the boundary around all of the ceiling parts
horiz_boundary = room.horizontal_boundary(tolerance=tolerance)
if horiz_boundary.normal.z <= 0: # ensure upward-facing Face3D
horiz_boundary = horiz_boundary.flip()
if horiz_boundary.has_holes: # remove any tiny holes
h_areas = [hp.area for hp in horiz_boundary.hole_polygon2d]
if not all(ha > IDA_ICE_BUILDING_BODY_TOL for ha in h_areas):
clean_holes = [hole for hole, ha in zip(horiz_boundary.holes, h_areas)
if ha > IDA_ICE_BUILDING_BODY_TOL]
horiz_boundary = Face3D(
horiz_boundary.boundary, horiz_boundary.plane, clean_holes)
# get the correctly ordered vertices at the Z height
vertices = [Point3D(pt.x, pt.y, max_pt.z) for pt in horiz_boundary.boundary]
full_bound = Face3D(vertices, plane=horiz_boundary.plane)
vertices = full_bound.lower_left_counter_clockwise_vertices
# translate the boundary vertices into an enclosing element
dpl = decimal_places
vertices_idm = ' '.join((
f'({round(v.x - origin.x, dpl)} '
f'{round(v.y - origin.y, dpl)} '
f'{round(v.z - origin.z, dpl)})'
for v in vertices
))
count = len(vertices)
ceiling = \
f'((ENCLOSING-ELEMENT :N CEILING_{faces[0].identifier} :T CEILING :INDEX -1000' \
')\n ((AGGREGATE :N GEOMETRY)\n' \
f' (:PAR :N CORNERS :DIM ({count} 3) :SP ({count} 3) :V #2A({vertices_idm})))'
ceiling_idm = [ceiling]
# write each of the ceiling faces to IDM
for fc, face in enumerate(faces):
name = f'{face.identifier}_{fc}'
holes = face.geometry.holes or []
contours = [list(face.geometry.boundary)] + [list(h) for h in holes]
vc = sum(len(c) for c in contours)
contours_formatted = ' '.join(str(len(c)) for c in contours)
vertices_idm = ' '.join(
f'({round(v.x - origin.x, dpl)} '
f'{round(v.y - origin.y, dpl)} '
f'{round(v.z - origin.z, dpl)})'
for vv in contours for v in vv
)
# add apertures and doors
windows = ['']
if face.has_sub_faces:
if angle_tolerance < face.tilt < 180 - angle_tolerance:
ref_plane = face_reference_plane(face, angle_tolerance)
else:
ref_plane = Plane(n=Vector3D(0, 0, 1), o=origin, x=Vector3D(1, 0, 0))
for aperture in face.apertures:
op_str = opening_to_idm(aperture, ref_plane, decimal_places=dpl,
angle_tolerance=angle_tolerance)
windows.append(op_str)
windows = ''.join(windows)
cp = f' ((ENCLOSING-ELEMENT :N "{name}" :T CEILING-PART :INDEX {-1001 - fc})\n' \
' ((AGGREGATE :N GEOMETRY)\n' \
f' (:PAR :N CORNERS :DIM ({vc} 3) :SP ({vc} 3) :V #2A({vertices_idm}))\n' \
f' (:PAR :N CONTOURS :V ({contours_formatted}))\n' \
f' (:PAR :N SLOPE :V {round(face.altitude + 90, 2)})){windows})'
ceiling_idm.append(cp)
return '\n'.join(ceiling_idm) + ')'
[docs]
def room_to_idm(
room: Room, tolerance: float, angle_tolerance: float = 1.0, decimal_places: int = 3
) -> str:
"""Translate a Honeybee Room to an IDM Zone.
Args:
room: A honeybee Room.
tolerance: The minimum difference between x, y, and z coordinate
values at which points are considered distinct.
angle_tolerance: The max angle in degrees that Face normal can differ
from the World Z before the Face is treated as being in the
World XY plane. (Default: 1).
decimal_places: An integer for the number of decimal places to which
coordinate values will be rounded. (Default: 3).
"""
room_idm = []
dpl = decimal_places
# get the contours and vertices from the horizontal boundary around the Room's floors
hz_bounds = room.horizontal_floor_boundaries(match_walls=True, tolerance=tolerance)
if not hz_bounds:
# skip this room
print(
'Failed to create a horizontal boundary for '
f'{room.display_name}[{room.identifier}]. This room will be skipped.'
)
return ''
contours = []
for hb in hz_bounds:
contours.append(hb.boundary)
if hb.has_holes:
contours.extend(list(h) for h in hb.holes)
contours_formatted = ' '.join(str(len(c)) for c in contours) \
if len(contours) > 1 else ''
vertices = [v for vl in contours for v in vl] # flattened list for vertices
# derive the origin and the lighting point from the largest floor Face3D
if len(hz_bounds) != 1:
hz_bounds.sort(key=lambda x: x.area, reverse=True)
horiz_boundary = hz_bounds[0]
if horiz_boundary.normal.z <= 0: # ensure upward-facing Face3D
horiz_boundary = horiz_boundary.flip()
origin = horiz_boundary.min
if horiz_boundary.is_convex:
pole = horiz_boundary.center
else: # use a 1 cm tolerance for pole that will not be time consuming to compute
pole = horiz_boundary.pole_of_inaccessibility(0.01)
# relative coordinates of the pole
rp = pole - origin
# arbitrary x and y size for lighting fixtures
lighting_x = 0.5
lighting_y = 0.5
min_x = round(rp.x - lighting_x / 2, dpl)
min_y = round(rp.y - lighting_y / 2, dpl)
# set the location of light and occupant
light_occ = '((LIGHT :N "Light" :T LIGHT)\n' \
f' (:PAR :N X :V {min_x})\n' \
f' (:PAR :N Y :V {min_y})\n' \
f' (:PAR :N DX :V {lighting_x})\n' \
f' (:PAR :N DY :V {lighting_y})\n' \
' (:PAR :N RATED_INPUT :V 50.0)\n' \
' (:RES :N SCHEDULE_0-1 :V ALWAYS_ON))\n' \
'((OCCUPANT :N "Occupant" :T OCCUPANT)\n' \
' (:PAR :N NUMBER_OF :V 1)\n' \
' (:RES :N SCHEDULE_0-1 :V ALWAYS_ON)\n' \
f' (:PAR :N POSITION :V #({round(rp.x, dpl)} {round(rp.y, dpl)} {0.6})))'
room_idm.append(light_occ)
count = len(vertices)
elevation = round(origin.z, dpl)
vertices_idm = ' '.join(
f'({round(v.x - origin.x, dpl)} {round(v.y - origin.y, dpl)})' for v in vertices
)
if not room.user_data['_idm_is_extruded']:
geometry = '((AGGREGATE :N GEOMETRY :X NIL)\n' \
' (:PAR :N PROTECTED_SHAPE :V :TRUE)\n' \
f' (:PAR :N ORIGIN :V #({origin.x} {origin.y}))\n' \
f' (:PAR :N NCORN :V {count})\n' \
f' (:PAR :N CORNERS :DIM ({count} 2) :V #2A({vertices_idm}))\n' \
f' (:PAR :N CONTOURS :V ({contours_formatted}))\n' \
f' (:PAR :N FLOOR_HEIGHT_FROM_GROUND :V {elevation}))'
else:
f_ceil_height = round(room.user_data["_idm_flr_ceil_height"], dpl)
geometry = '((AGGREGATE :N GEOMETRY :X NIL)\n' \
f' (:PAR :N ORIGIN :V #({origin.x} {origin.y}))\n' \
f' (:PAR :N NCORN :V {count})\n' \
f' (:PAR :N CORNERS :DIM ({count} 2) :V #2A({vertices_idm}))\n' \
f' (:PAR :N CONTOURS :V ({contours_formatted}))\n' \
f' (:PAR :N CEILING-HEIGHT :V {f_ceil_height})\n' \
f' (:PAR :N FLOOR_HEIGHT_FROM_GROUND :V {elevation}))'
room_idm.append(geometry)
walls, _, floors = deconstruct_room(room)
# write faces
used_index = []
last_index = len(walls) + 1
for wall in walls:
urc = wall.geometry.upper_right_corner
sorted_vertices = sorted(vertices, key=lambda x: x.distance_to_point(urc))
index = vertices.index(sorted_vertices[0]) + 1
if index in used_index:
# this is a vertical segment of a wall with the same starting point.
# use a new index and hope it doesn't have an aperture
index = last_index
last_index += 1
used_index.append(index)
face_idm = face_to_idm(
wall, origin=origin, index=index, angle_tolerance=angle_tolerance,
decimal_places=dpl
)
room_idm.append(face_idm)
for count, floor in enumerate(floors):
face_idm = face_to_idm(
floor, origin=origin, index=-(2000 + count),
angle_tolerance=angle_tolerance, decimal_places=dpl
)
room_idm.append(face_idm)
ceiling_idm = ceilings_to_idm(
room, origin=origin, tolerance=tolerance,
angle_tolerance=angle_tolerance, decimal_places=dpl
)
room_idm.append(ceiling_idm)
return '\n'.join(room_idm)
[docs]
def deconstruct_room(room: Room):
"""Deconstruct a room into walls, ceilings and floors."""
walls = []
floors = []
ceilings = []
for face in room.faces:
type_ = face.type
if isinstance(type_, RoofCeiling):
ceilings.append(face)
elif isinstance(type_, Floor):
floors.append(face)
else:
# TODO: support air boundaries
walls.append(face)
return walls, ceilings, floors
def _is_room_extruded(room: Room, tolerance: float, angle_tolerance: float) -> Tuple:
"""Check if the room geometry is an extrusion in Z direction.
Args:
room: A honeybee Room.
tolerance: The minimum difference between coordinate values at which point
vertices are considered distinct.
angle_tolerance: The max angle difference in degrees that the normals of
Faces are allowed to differ from vertical/horizontal for the Room
to not be considered extruded.
"""
f_hs = []
c_hs = []
for face in room.faces:
type_ = face.type
if isinstance(type_, Wall):
if abs(face.altitude) > angle_tolerance:
return False, -1
elif isinstance(type_, RoofCeiling):
if abs(90 - face.altitude) > angle_tolerance:
return False, -1
c_hs.append(face.vertices[0].z)
elif isinstance(type_, Floor):
if abs(face.altitude + 90) > angle_tolerance:
return False, -1
f_hs.append(face.vertices[0].z)
if len(f_hs) != 0 and len(c_hs) != 0 and max(c_hs) - min(c_hs) < tolerance and \
max(f_hs) - min(f_hs) < tolerance:
return True, round(room.max.z - room.min.z, 2)
return False, -1
[docs]
def prepare_model(model: Model, max_int_wall_thickness: float = 0.45) -> Model:
"""Perform a number of model edits to prepare it for translation to IDM.
* Check room display names and ensure they are unique
* Mark rooms as extruded and non-extruded
* Mark doors and apertures in the model to avoid writing duplicated doors and
apertures
Args:
model: A honeybee model.
max_int_wall_thickness: Maximum thickness of the interior wall in meters.
This will be used to identify adjacent interior doors and apertures
in the model to ensure that only one version of the geometry
is written to IDA-ICE. (Default: 0.45).
"""
# difference in normal angles that make apertures/doors adjacent
min_ang = math.pi - math.radians(model.angle_tolerance)
# ensure unique room names for each story and make a note of adjacencies
room_names = {}
grouped_rooms, _ = Room.group_by_floor_height(
model.rooms, min_difference=MAX_FLOOR_ELEVATION_DIFFERENCE)
door_tracker = []
aperture_tracker = []
for grouped_room in grouped_rooms:
for room in grouped_room:
# check the display name and change it if it is not unique
room.display_name = \
room.display_name.replace('/', '-').replace('\\', '-') \
.replace('\n', ' ').replace(':', '.')
if room.display_name in room_names:
original_name = room.display_name
room.display_name = \
f'{room.display_name}_{room_names[original_name]}'
room_names[original_name] += 1
else:
room_names[room.display_name] = 1
# add markers for whether the Room is extruded or not
is_extruded, floor_to_ceiling_height = \
_is_room_extruded(room, model.tolerance, model.angle_tolerance)
room.user_data = {
'_idm_is_extruded': is_extruded,
'_idm_flr_ceil_height': floor_to_ceiling_height
}
# add markers so adjacent interior Apertures and Doors are not duplicated
for face in room.faces:
# remove AirBoundaries until we learn how to support them
if isinstance(face.type, AirBoundary):
face.type = get_type_from_normal(face.normal)
for door in face.doors:
center = door.geometry.center
normal = door.geometry.normal
for data in door_tracker:
c, n = data
if c.distance_to_point(center) <= max_int_wall_thickness \
and n.angle(normal) > min_ang:
door.user_data = {'_idm_ignore': True}
break
door_tracker.append((center, normal))
for aperture in face.apertures:
center = aperture.geometry.center
normal = aperture.geometry.normal
for data in aperture_tracker:
c, n = data
if c.distance_to_point(center) <= max_int_wall_thickness \
and n.angle(normal) > min_ang:
aperture.user_data = {'_idm_ignore': True}
aperture_tracker.append((center, normal))
[docs]
def prepare_folder(bldg_name: str, out_folder: str) -> List[pathlib.Path]:
"""Prepare folders for IDM file."""
base_folder = pathlib.Path(out_folder)
model_folder = base_folder.joinpath(bldg_name)
if model_folder.exists():
shutil.rmtree(model_folder.as_posix())
model_folder.mkdir(parents=True, exist_ok=True)
# create the entry file
bldg_folder = model_folder.joinpath(f'{bldg_name}')
bldg_folder.mkdir(parents=True, exist_ok=True)
bldg_file = model_folder.joinpath(f'{bldg_name}.idm')
return base_folder, model_folder, bldg_folder, bldg_file
[docs]
def model_to_idm(
model: Model, out_folder: pathlib.Path, name: str = None,
max_int_wall_thickness: float = 0.40, max_adjacent_sub_face_dist: float = 0.40,
debug: bool = False):
"""Translate a Honeybee model to an IDM file.
Args:
model: A honeybee model.
out_folder: Output folder for idm file.
name: Output IDM file name.
max_int_wall_thickness: Maximum thickness of the interior wall in meters. IDA-ICE
expects the input model to have a gap between the rooms that represents
the wall thickness. This value must be smaller than the smallest Room
that is expected in resulting IDA-ICE model and it should never be greater
than 0.5 in order to avoid creating invalid building bodies for IDA-ICE.
For models where the walls are touching each other, use a value
of 0. (Default: 0.40).
max_adjacent_sub_face_dist: The maximum distance in meters between interior
Apertures and Doors at which they are considered adjacent. This is used to
ensure that only one interior Aperture of an adjacent pair is written into
the IDM. This value should typically be around the max_int_wall_thickness
and should ideally not be thicker than 0.5. But it may be undesirable to
set this to zero (like some cases of max_int_wall_thickness),
particularly when the adjacent interior geometries are not matching
one another. (Default: 0.40).
debug: Set to True to not to delete the IDM folder before zipping it into a
single file.
"""
# check for the presence of rooms
VERSION = '5.00001'
if not model.rooms:
raise ValueError(
'The model must have at least have one room to translate to IDM.')
# duplicate model to avoid mutating it as we edit it for export
# otherwise, we'll loose the original model if we want to do anything after export
model = model.duplicate()
# scale the model if the units are not meters
if model.units != 'Meters':
model.convert_to_units('Meters')
# remove degenerate geometry within the model tolerance
model.remove_degenerate_geometry()
# merge coplanar faces across the model's rooms
for room in model.rooms:
room.merge_coplanar_faces(
model.tolerance, model.angle_tolerance, orthogonal_only=True)
# edit the model display_names and add user_data to help with the translation
adj_dist = max_adjacent_sub_face_dist \
if max_adjacent_sub_face_dist > model.tolerance else model.tolerance
prepare_model(model, adj_dist)
# determine the number of places to which all of the vertices will be rounded
try:
dec_count = (int(math.log10(model.tolerance)) * -1) + 1
except ValueError: # someone used a tolerance of zero
dec_count = 0
# make sure names don't have subfolder or extension
original_name = name or model.display_name
name = pathlib.Path(original_name).stem
bldg_name = name or model.display_name
base_folder, model_folder, bldg_folder, bldg_file = \
prepare_folder(bldg_name, out_folder)
__here__ = pathlib.Path(__file__).parent
templates_folder = __here__.joinpath('templates')
# create building file that includes building bodies and a reference to the rooms
with bldg_file.open('w', encoding='utf-8') as bldg:
header = f';IDA {VERSION} Data UTF-8\n' \
f'(DOCUMENT-HEADER :TYPE BUILDING :N "{bldg_name}" :MS 6 :CK ' \
'((RECENT (WINDEF . "Double Clear Air (WIN7)"))) ' \
f':PARENT ICE :APP (ICE :VER {VERSION}))\n'
bldg.write(header)
# add template values
bldg_template = templates_folder.joinpath('building.idm')
for count, line in enumerate(bldg_template.open('r', encoding='utf-8')):
# this is to remove the random bug that adds new character at
# the start of the line
if count == 0 and line[0] != '(':
line = line[1:]
bldg.write(line)
# site object
site_idm = '((SITE-OBJECT :N SITE)\n' \
' (:PAR :N SITE-AREA :V #(-100.0 -80.0 150.0 100.0))\n'
bldg.write(site_idm)
has_shade = model.shade_meshes or model.shades
if has_shade:
bldg.write(' ((AGGREGATE :N ARCDATA)\n')
# add shades to building if any
shades_idm = shades_to_idm(model.shades, model.tolerance, dec_count)
bldg.write(shades_idm)
shades_idm = shade_meshes_to_idm(model.shade_meshes, model.tolerance, dec_count)
bldg.write(shades_idm)
# end of site object
if has_shade:
bldg.write('))\n')
else:
bldg.write(')\n')
# create a building sections/bodies for the building
sections = section_to_idm(
model, max_int_wall_thickness=max_int_wall_thickness,
decimal_places=dec_count
)
bldg.write(sections)
# add reference to rooms as zones
for room in model.rooms:
bldg.write(f'((CE-ZONE :N "{room.display_name}" :T ZONE))\n')
bldg.write(f'\n;[end of {bldg_name}.idm]\n')
# copy all the template files
templates = [
'plant.idm', 'ahu.idc', 'ahu.idm', 'electrical system.idm'
]
for template in templates:
template_file = templates_folder.joinpath(template)
target_file = bldg_folder.joinpath(template)
with target_file.open('w', encoding='utf-8') as outf, \
template_file.open('r', encoding='utf-8') as inf:
for line in inf:
outf.write(f'{line.rstrip()}\n')
outf.write(f';[end of {bldg_name}\\{template_file}]\n')
# write rooms
template_room = templates_folder.joinpath('room.idm')
for room in model.rooms:
room_name = room.display_name
room_file = bldg_folder.joinpath(f'{room_name}.idm')
with template_room.open('r', encoding='utf-8') as inf, \
room_file.open('w', encoding='utf-8') as rm:
for line in inf:
rm.write(f'{line.rstrip()}\n')
geometry = room_to_idm(
room, model.tolerance, model.angle_tolerance, dec_count
)
rm.write(geometry)
footer = f'\n;[end of {bldg_name}\\{room_name}.idm]\n'
rm.write(footer)
if not original_name.endswith('.idm'):
original_name = f'{original_name}.idm'
idm_file = base_folder.joinpath(original_name)
zip_folder_to_idm(model_folder, idm_file)
# clean up the folder
if not debug:
shutil.rmtree(model_folder, ignore_errors=True)
return idm_file