|
| 1 | +# Ifc4D - IFC scheduling utility |
| 2 | +# Copyright (C) 2022 Dion Moult <dion@thinkmoult.com> |
| 3 | +# |
| 4 | +# This file is part of Ifc4D. |
| 5 | +# |
| 6 | +# Ifc4D is free software: you can redistribute it and/or modify |
| 7 | +# it under the terms of the GNU Lesser General Public License as published by |
| 8 | +# the Free Software Foundation, either version 3 of the License, or |
| 9 | +# (at your option) any later version. |
| 10 | +# |
| 11 | +# Ifc4D is distributed in the hope that it will be useful, |
| 12 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 13 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 14 | +# GNU Lesser General Public License for more details. |
| 15 | +# |
| 16 | +# You should have received a copy of the GNU Lesser General Public License |
| 17 | +# along with Ifc4D. If not, see <http://www.gnu.org/licenses/>. |
| 18 | + |
| 19 | +import uuid |
| 20 | +import datetime |
| 21 | +import ifcopenshell |
| 22 | +import ifcopenshell.util.date |
| 23 | +import xml.etree.ElementTree as ET |
| 24 | + |
| 25 | + |
| 26 | +class Ifc2Msp: |
| 27 | + def __init__(self): |
| 28 | + self.xml = None |
| 29 | + self.file = None |
| 30 | + self.id = 0 |
| 31 | + self.id_map = {} |
| 32 | + self.element_map = {} |
| 33 | + self.hours_per_day = 8 |
| 34 | + self.project_tasks = [] |
| 35 | + self.start_dates = [] |
| 36 | + self.base_calendar = None |
| 37 | + # P6XML does not understand patterns in holiday dates. Instead, it |
| 38 | + # expects a list of every day that is a holiday between a date range. |
| 39 | + # This allows you to specify the date range where the export should |
| 40 | + # calculate holidays for. |
| 41 | + self.holiday_start_date = None |
| 42 | + self.holiday_finish_date = None |
| 43 | + self.work_schedule = None |
| 44 | + |
| 45 | + def execute(self): |
| 46 | + self.root = ET.Element("Project") |
| 47 | + self.root.attrib["xmlns"] = "http://schemas.microsoft.com/project" |
| 48 | + |
| 49 | + self.schedule = self.file.by_type("IfcWorkSchedule")[0] |
| 50 | + |
| 51 | + self.create_project() |
| 52 | + self.create_calendars() |
| 53 | + self.create_tasks() |
| 54 | + |
| 55 | + self.set_start_date() |
| 56 | + self.set_base_calendar() |
| 57 | + |
| 58 | + tree = ET.ElementTree(self.root) |
| 59 | + tree.write(self.xml, encoding="utf-8", xml_declaration=True) |
| 60 | + |
| 61 | + def create_project(self): |
| 62 | + # Which version should we pick? I don't know. Hardcode as 14 until it breaks :) |
| 63 | + # Values are: 12=Project 2007, 24=Project 2010 |
| 64 | + ET.SubElement(self.root, "SaveVersion").text = "14" |
| 65 | + element = self.file.by_type("IfcProject")[0] |
| 66 | + self.link_element(element, self.root) |
| 67 | + ET.SubElement(self.root, "GUID").text = str(uuid.UUID(ifcopenshell.guid.expand(element.GlobalId))).upper() |
| 68 | + ET.SubElement(self.root, "Name").text = element.Name or "X" |
| 69 | + ET.SubElement(self.root, "Title").text = element.LongName or "Unnamed" |
| 70 | + # This is mandatory, but we don't use it |
| 71 | + ET.SubElement(self.root, "CurrencyCode").text = "USD" |
| 72 | + ET.SubElement(self.root, "DurationFormat").text = "7" |
| 73 | + ET.SubElement(self.root, "MinutesPerDay").text = "480" |
| 74 | + |
| 75 | + def set_start_date(self): |
| 76 | + ET.SubElement(self.root, "ScheduleFromStart").text = "1" |
| 77 | + if self.start_dates: |
| 78 | + # Set to midnight as a workaround. If I don't, the first duration seems to stick to zero. |
| 79 | + ET.SubElement(self.root, "StartDate").text = sorted(self.start_dates)[0].split("T")[0] + "T00:00:00" |
| 80 | + |
| 81 | + def set_base_calendar(self): |
| 82 | + if self.base_calendar: |
| 83 | + ET.SubElement(self.root, "CalendarUID").text = self.id_map[self.base_calendar] |
| 84 | + |
| 85 | + def create_calendars(self): |
| 86 | + el = ET.SubElement(self.root, "Calendars") |
| 87 | + for calendar in self.file.by_type("IfcWorkCalendar"): |
| 88 | + self.create_calendar(calendar, el) |
| 89 | + |
| 90 | + def create_calendar(self, calendar, parent): |
| 91 | + el = ET.SubElement(parent, "Calendar") |
| 92 | + self.link_element(calendar, el) |
| 93 | + ET.SubElement(el, "Name").text = calendar.Name or "Unnamed" |
| 94 | + ET.SubElement(el, "IsBaseCalendar").text = "1" |
| 95 | + |
| 96 | + working_week = self.auto_detect_working_week(calendar) |
| 97 | + |
| 98 | + if not working_week: |
| 99 | + working_week = self.get_default_working_week() |
| 100 | + |
| 101 | + week_days = ET.SubElement(el, "WeekDays") |
| 102 | + |
| 103 | + day_map = { |
| 104 | + "Sunday": "1", |
| 105 | + "Monday": "2", |
| 106 | + "Tuesday": "3", |
| 107 | + "Wednesday": "4", |
| 108 | + "Thursday": "5", |
| 109 | + "Friday": "6", |
| 110 | + "Saturday": "7", |
| 111 | + } |
| 112 | + |
| 113 | + for day in ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]: |
| 114 | + time_periods = working_week.get(day, None) |
| 115 | + if time_periods: |
| 116 | + week_day = ET.SubElement(week_days, "WeekDay") |
| 117 | + ET.SubElement(week_day, "DayType").text = day_map[day] |
| 118 | + ET.SubElement(week_day, "DayWorking").text = "1" |
| 119 | + working_times = ET.SubElement(week_day, "WorkingTimes") |
| 120 | + for time_period in time_periods: |
| 121 | + working_time = ET.SubElement(working_times, "WorkingTime") |
| 122 | + ET.SubElement(working_time, "FromTime").text = time_period["Start"] |
| 123 | + ET.SubElement(working_time, "ToTime").text = time_period["Finish"] |
| 124 | + else: |
| 125 | + week_day = ET.SubElement(week_days, "WeekDay") |
| 126 | + ET.SubElement(week_day, "DayType").text = day_map[day] |
| 127 | + ET.SubElement(week_day, "DayWorking").text = "0" |
| 128 | + |
| 129 | + # TODO Exceptions not yet implemented |
| 130 | + return |
| 131 | + |
| 132 | + holidays = ET.SubElement(el, "Exceptions") |
| 133 | + for holiday in self.auto_detect_holidays(calendar): |
| 134 | + el = ET.SubElement(holidays, "Exception") |
| 135 | + # Recurrence is supported but I haven't implemented it yet |
| 136 | + holiday = ifcopenshell.util.date.datetime2ifc(holiday, "IfcDateTime") |
| 137 | + time_period = ET.SubElement(el, "TimePeriod") |
| 138 | + ET.SubElement(time_period, "FromDate").text = holiday |
| 139 | + ET.SubElement(time_period, "ToDate").text = holiday |
| 140 | + |
| 141 | + def auto_detect_working_week(self, calendar): |
| 142 | + # Microsoft Project XML only understands a working week. In other words, |
| 143 | + # it understands the equivalent of an IFC weekly recurring time period. |
| 144 | + # If you do not have a weekly recurring time period, we just give a |
| 145 | + # default working week. |
| 146 | + results = {} |
| 147 | + weekday_component_map = { |
| 148 | + 1: "Monday", |
| 149 | + 2: "Tuesday", |
| 150 | + 3: "Wednesday", |
| 151 | + 4: "Thursday", |
| 152 | + 5: "Friday", |
| 153 | + 6: "Saturday", |
| 154 | + 7: "Sunday", |
| 155 | + } |
| 156 | + for working_time in calendar.WorkingTimes: |
| 157 | + if not working_time.RecurrencePattern or working_time.RecurrencePattern.RecurrenceType != "WEEKLY": |
| 158 | + continue |
| 159 | + |
| 160 | + if not working_time.RecurrencePattern.TimePeriods: |
| 161 | + time_periods = [{"Start": "09:00:00", "Finish": "17:00:00"}] |
| 162 | + else: |
| 163 | + time_periods = [ |
| 164 | + {"Start": p.StartTime, "Finish": p.EndTime} for p in working_time.RecurrencePattern.TimePeriods |
| 165 | + ] |
| 166 | + |
| 167 | + for component in working_time.RecurrencePattern.WeekdayComponent: |
| 168 | + results.setdefault(weekday_component_map[component], []).extend(time_periods) |
| 169 | + |
| 170 | + return results |
| 171 | + |
| 172 | + def get_default_working_week(self): |
| 173 | + results = {} |
| 174 | + # As a fallback, we work 5 days a week, 8 hours per day. |
| 175 | + for name in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]: |
| 176 | + results[name] = [{"Start": "09:00:00", "Finish": "17:00:00"}] |
| 177 | + return results |
| 178 | + |
| 179 | + def auto_detect_holidays(self, calendar): |
| 180 | + results = [] |
| 181 | + start = self.holiday_start_date |
| 182 | + finish = self.holiday_finish_date |
| 183 | + if finish < start: |
| 184 | + return results |
| 185 | + while start < finish: |
| 186 | + start += datetime.timedelta(days=1) |
| 187 | + if not ifcopenshell.util.sequence.is_working_day(start, calendar): |
| 188 | + results.append(start) |
| 189 | + return results |
| 190 | + |
| 191 | + def create_tasks(self): |
| 192 | + self.tasks = ET.SubElement(self.root, "Tasks") |
| 193 | + for task in self.get_subtasks(self.work_schedule): |
| 194 | + self.create_task(task) |
| 195 | + |
| 196 | + def create_task(self, task, level=0): |
| 197 | + el = ET.SubElement(self.tasks, "Task") |
| 198 | + self.link_element(task, el) |
| 199 | + ET.SubElement(el, "Name").text = task.Name or "Unnamed" |
| 200 | + ET.SubElement(el, "OutlineNumber").text = task.Identification or "X" |
| 201 | + ET.SubElement(el, "OutlineLevel").text = str(level) |
| 202 | + ET.SubElement(el, "DurationFormat").text = "53" |
| 203 | + if task.TaskTime: |
| 204 | + if task.TaskTime.ScheduleDuration: |
| 205 | + duration = ifcopenshell.util.date.ifc2datetime(task.TaskTime.ScheduleDuration) |
| 206 | + ET.SubElement(el, "Duration").text = f"PT{duration.days * 8}H0M0S" |
| 207 | + if task.TaskTime.ScheduleStart: |
| 208 | + ET.SubElement(el, "Start").text = task.TaskTime.ScheduleStart |
| 209 | + self.start_dates.append(task.TaskTime.ScheduleStart) |
| 210 | + if task.TaskTime.ScheduleFinish: |
| 211 | + ET.SubElement(el, "Finish").text = task.TaskTime.ScheduleFinish |
| 212 | + ET.SubElement(el, "Critical").text = "1" if task.TaskTime.IsCritical else "0" |
| 213 | + |
| 214 | + data_map = { |
| 215 | + "EarlyStart": task.TaskTime.EarlyStart, |
| 216 | + "EarlyFinish": task.TaskTime.EarlyFinish, |
| 217 | + "LateStart": task.TaskTime.LateStart, |
| 218 | + "LateFinish": task.TaskTime.LateFinish, |
| 219 | + } |
| 220 | + for key, value in data_map.items(): |
| 221 | + if value: |
| 222 | + ET.SubElement(el, key).text = value |
| 223 | + |
| 224 | + calendar = ifcopenshell.util.sequence.derive_calendar(task) |
| 225 | + if calendar: |
| 226 | + ET.SubElement(el, "CalendarUID").text = self.id_map[calendar] |
| 227 | + if level == 0: |
| 228 | + # This must be the base calendar |
| 229 | + self.base_calendar = calendar |
| 230 | + |
| 231 | + for rel in task.IsSuccessorFrom or []: |
| 232 | + predecessor = rel.RelatingProcess |
| 233 | + predecessor_link = ET.SubElement(el, "PredecessorLink") |
| 234 | + self.link_element(rel, predecessor_link) |
| 235 | + if rel.TimeLag and rel.TimeLag.LagValue: |
| 236 | + duration = ifcopenshell.util.date.ifc2datetime(rel.TimeLag.LagValue.wrappedValue) |
| 237 | + # Lag time is expressed in tenths of a minute. Seriously, Microsoft? |
| 238 | + ET.SubElement(predecessor_link, "LinkLag").text = str(duration.days * self.hours_per_day * 60 * 10) |
| 239 | + else: |
| 240 | + ET.SubElement(predecessor_link, "LinkLag").text = "0" |
| 241 | + ET.SubElement(predecessor_link, "PredecessorUID").text = str(predecessor.id()) |
| 242 | + ET.SubElement(predecessor_link, "Type").text = { |
| 243 | + "START_START": "3", |
| 244 | + "START_FINISH": "2", |
| 245 | + "FINISH_START": "1", |
| 246 | + "FINISH_FINISH": "0", |
| 247 | + "NOTDEFINED": "1", |
| 248 | + "USERDEFINED": "1", |
| 249 | + None: "1", |
| 250 | + }[rel.SequenceType] |
| 251 | + |
| 252 | + for subtask in self.get_subtasks(task): |
| 253 | + self.create_task(subtask, level=level + 1) |
| 254 | + |
| 255 | + def get_subtasks(self, element): |
| 256 | + tasks = [] |
| 257 | + if element.is_a("IfcWorkSchedule"): |
| 258 | + for rel in element.Controls or []: |
| 259 | + tasks.extend([e for e in rel.RelatedObjects if e.is_a("IfcTask")]) |
| 260 | + elif element.is_a("IfcTask"): |
| 261 | + for rel in element.IsNestedBy or []: |
| 262 | + tasks.extend([e for e in rel.RelatedObjects if e.is_a("IfcTask")]) |
| 263 | + return tasks |
| 264 | + |
| 265 | + def link_element(self, element, el): |
| 266 | + ET.SubElement(el, "UID").text = str(element.id()) |
| 267 | + ET.SubElement(el, "GUID").text = str(uuid.UUID(ifcopenshell.guid.expand(element.GlobalId))).upper() |
| 268 | + self.id_map[element] = str(element.id()) |
| 269 | + self.element_map[element] = el |
0 commit comments