Skip to content

Commit 3cf9017

Browse files
committed
New IFC2MSP conversion tool in IFC4D to convert IFC to Microsoft Project
1 parent 2147948 commit 3cf9017

2 files changed

Lines changed: 273 additions & 1 deletion

File tree

src/ifc4d/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ Ifc4D contains a series of utilities for converting to and from various 4D softw
77
- Oracle Primavera 6 (P6) XER to IFC
88
- Asta Powerproject to IFC
99
- IFC to Oracle Primavera 6 (P6) XML
10+
- IFC to Microsoft Project
1011

1112
Planned (would you like to contribute? Please reach out!):
1213

13-
- IFC to Microsoft Project
1414
- IFC to Oracle Primavera 6 (P6) XER
1515
- IFC to Oracle Primavera 6 (P6) XLS
1616
- IFC to Asta Powerproject
@@ -21,3 +21,6 @@ Planned (would you like to contribute? Please reach out!):
2121
## Useful links
2222

2323
- [P6 EPPM XER Import/Export Data Map Guide](https://docs.oracle.com/cd/F12057_01/English/Mapping_and_Schema/xer_import_export_data_map_project/helpmain.htm?toc.htm?97881.htm)
24+
- [Microsoft Project 2007 XML XSD](https://schemas.microsoft.com/project/2007/mspdi_pj12.xsd)
25+
- [Microsoft Project XML Documentation](https://docs.microsoft.com/en-us/office-project/xml-data-interchange/project-xml-data-interchange-schema-reference?view=project-client-2016)
26+
- [Microsoft Project 2010 SDK Download](https://www.microsoft.com/en-us/download/details.aspx?id=15511) (needed to get `mspdi_pj14.xsd`)

src/ifc4d/ifc4d/ifc2msp.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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

Comments
 (0)