diff --git a/src/ifc4d/ifc4d/common.py b/src/ifc4d/ifc4d/common.py index 783680b760..a470bc626b 100644 --- a/src/ifc4d/ifc4d/common.py +++ b/src/ifc4d/ifc4d/common.py @@ -77,6 +77,13 @@ class ScheduleIfcGenerator: file: Union[ifcopenshell.file, None] calendars: dict[int, Calendar] + # The property sets an activity's user-defined fields and codes land in. + # Named for P6 because that is where the shape came from; an importer for + # another tool overrides them rather than filing its own fields under a + # name that names the wrong tool. + udf_pset_name = "P6_UDF" + code_pset_name = "P6_ActivityCodes" + def __init__(self, file: Union[ifcopenshell.file, None], output, settings): self.file = file self.work_plan = settings["work_plan"] @@ -305,7 +312,11 @@ class ScheduleIfcGenerator: "Name": activity["Name"], "Identification": str(activity["Identification"]), "Status": activity["Status"], - "IsMilestone": activity["StartDate"] == activity["FinishDate"], + # A zero-length activity is a milestone, which is all P6 gives + # us to go on. A source that says so outright — Microsoft + # Project has a Milestone flag — is believed instead, because a + # tool can mark a task a milestone without zeroing its duration. + "IsMilestone": activity.get("IsMilestone", activity["StartDate"] == activity["FinishDate"]), # A P6 Level of Effort activity has no duration of its own: it # stretches to span whatever it hangs off, and its dates are # derived from its relationships rather than planned. IFC has no @@ -386,7 +397,7 @@ class ScheduleIfcGenerator: udfs = activity.get("UDFs") if not udfs: return - pset = ifcopenshell.api.pset.add_pset(self.file, product=activity["ifc"], name="P6_UDF") + pset = ifcopenshell.api.pset.add_pset(self.file, product=activity["ifc"], name=self.udf_pset_name) ifcopenshell.api.pset.edit_pset( self.file, pset=pset, @@ -399,7 +410,7 @@ class ScheduleIfcGenerator: codes = activity.get("Codes") if not codes: return - pset = ifcopenshell.api.pset.add_pset(self.file, product=activity["ifc"], name="P6_ActivityCodes") + pset = ifcopenshell.api.pset.add_pset(self.file, product=activity["ifc"], name=self.code_pset_name) ifcopenshell.api.pset.edit_pset( self.file, pset=pset, diff --git a/src/ifc4d/ifc4d/msp2ifc.py b/src/ifc4d/ifc4d/msp2ifc.py index 91ae683c84..72c0d041a6 100644 --- a/src/ifc4d/ifc4d/msp2ifc.py +++ b/src/ifc4d/ifc4d/msp2ifc.py @@ -16,434 +16,616 @@ # You should have received a copy of the GNU Lesser General Public License # along with Ifc4D. If not, see . +"""Microsoft Project XML in, an IFC4 work schedule out. + + converter = MSP2Ifc() + converter.xml = "programme.xml" + converter.output = "programme.ifc" + converter.execute() + +The parse is MSP-specific; everything from the parsed programme onwards is +ScheduleIfcGenerator, shared with the P6 and Powerproject importers, so a +schedule reads the same whichever tool it came out of. + +Two things about MS Project need saying, because they are what the shape of +this module is for. + +The first is that MS Project has no work breakdown structure as a thing of its +own. It has one flat task list and an OutlineLevel column, and a task with +anything indented under it is a summary — its dates and duration are rolled up +from its children rather than planned. Those become IfcTasks without an +IfcTaskTime, the same as a P6 WBS node, and the leaves below them become the +activities. That is what makes "has this task an IfcTaskTime" a usable test for +"is this real work" in a file this importer wrote. + +The second is that summaries and leaves are interleaved. A P6 WBS node holds +its child nodes and its activities in separate collections, so their relative +order is not a question anyone can ask; in MS Project a planner can put a +summary between two ordinary tasks and expects it to stay there. So the tree is +walked here in the order the export lists it, rather than through the +generator's create_tasks, which sorts nodes ahead of activities. +""" + +from __future__ import annotations + import datetime import xml.etree.ElementTree as ET -from datetime import timedelta -import ifcopenshell -import ifcopenshell.api.control import ifcopenshell.api.pset -import ifcopenshell.api.root import ifcopenshell.api.sequence +import ifcopenshell.guid import ifcopenshell.util.date +from .common import ScheduleIfcGenerator + +# MS Project counts weekdays from Sunday. IFC counts from Monday, but the +# generator takes the day by name, so the names are all that is needed here. +DAY_TYPES = { + "1": "Sunday", + "2": "Monday", + "3": "Tuesday", + "4": "Wednesday", + "5": "Thursday", + "6": "Friday", + "7": "Saturday", +} + +# https://learn.microsoft.com/en-us/office-project/xml-data-interchange/type-element-predecessorlink +SEQUENCE_TYPES = { + "0": "FINISH_FINISH", + "1": "FINISH_START", + "2": "START_FINISH", + "3": "START_START", +} + +# A lag is normally a length of time, expressed in tenths of a minute whatever +# unit the LagFormat says it is displayed in. These two formats are the +# exception: the lag is a percentage of the predecessor's duration, which is +# not a duration and cannot be converted into one without the predecessor. +# https://learn.microsoft.com/en-us/office-project/xml-data-interchange/lagformat-element-predecessorlink +PERCENT_LAG_FORMATS = {"19", "20"} + +TENTHS_OF_A_MINUTE_PER_HOUR = 600 + + +class MSPIfcGenerator(ScheduleIfcGenerator): + """ScheduleIfcGenerator, with MS Project's flat outline in place of a WBS.""" + + udf_pset_name = "MSP_ExtendedAttribute" + + def __init__(self, file, output, settings): + super().__init__(file, output, settings) + # uid -> the uids indented directly under it, in the order the export + # lists them, summaries and leaves together. + self.children = settings["children"] + self.roots = settings["roots"] + self.default_hours_per_day = settings["hours_per_day"] + + def create_tasks(self, work_schedule): + for uid in self.roots: + self.create_node(uid, None, work_schedule) + + def create_node(self, uid, parent, work_schedule): + """One outline row, and everything indented under it.""" + if uid not in self.wbs: + self.create_task_from_activity(self.activities[uid], parent, work_schedule) + return + + wbs = self.wbs[uid] + wbs["ifc"] = ifcopenshell.api.sequence.add_task( + self.file, + work_schedule=None if parent else work_schedule, + parent_task=parent["ifc"] if parent else None, + ) + ifcopenshell.api.sequence.edit_task( + self.file, + task=wbs["ifc"], + # MS Project's OutlineNumber is already the full dotted path, so + # unlike P6's WBS Code it needs no assembling from its ancestors. + attributes={"Name": wbs["Name"], "Identification": str(wbs["Code"])}, + ) + # A summary task's dates are a roll-up and are dropped, but its custom + # fields are not — a planner fills those in on a summary row as readily + # as on a leaf, and nothing else in the file carries them. + self.create_udf_pset(wbs) + for child in self.children.get(uid, []): + self.create_node(child, wbs, None) + + def create_task_from_activity(self, activity, wbs, work_schedule): + super().create_task_from_activity(activity, wbs, work_schedule) + columns = activity.get("OptionalColumns") + if columns: + pset = ifcopenshell.api.pset.add_pset(self.file, product=activity["ifc"], name="Pset_MSP_Task") + ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties=columns) + + def create_rel_sequences(self): + """The links, including any that hang off a summary task. + + The generator's own resolves both ends against the activities, which + is safe in P6 where a WBS node cannot be one. MS Project lets a planner + link a summary, and IFC is content either way — IfcRelSequence relates + two IfcProcesses and does not care whether either has a time. + """ + for relationship in self.relationships.values(): + predecessor = self.task_for(relationship["PredecessorActivity"]) + successor = self.task_for(relationship["SuccessorActivity"]) + if predecessor is None or successor is None: + continue + + sequence_type = relationship["Type"] + rel_sequence = next( + ( + rel + for rel in successor.IsSuccessorFrom or [] + if rel.RelatingProcess == predecessor and rel.SequenceType == sequence_type + ), + None, + ) + if rel_sequence is None: + rel_sequence = self.file.create_entity( + "IfcRelSequence", + GlobalId=ifcopenshell.guid.new(), + RelatingProcess=predecessor, + RelatedProcess=successor, + SequenceType=sequence_type, + ) + if relationship["Lag"]: + ifcopenshell.api.sequence.assign_lag_time( + self.file, + rel_sequence=rel_sequence, + lag_value=datetime.timedelta(days=relationship["Lag"] / self.hours_per_day(predecessor)), + duration_type="WORKTIME", + ) + + def task_for(self, uid): + if uid in self.activities: + return self.activities[uid]["ifc"] + if uid in self.wbs: + return self.wbs[uid]["ifc"] + return None + + def hours_per_day(self, task): + """The working day a lag against this task is counted in.""" + for rel in task.HasAssignments or []: + if rel.is_a("IfcRelAssignsToControl") and rel.RelatingControl.is_a("IfcWorkCalendar"): + calendar = self.calendars.get(rel.RelatingControl.Identification) + if calendar and calendar["HoursPerDay"]: + return float(calendar["HoursPerDay"]) + return float(self.default_hours_per_day) + class MSP2Ifc: def __init__(self, optionalColumns: list[str] = []): self.xml = None self.file = None + self.output = None self.ns = None self.work_plan = None self.project = {} self.calendars = {} - self.tasks = {} - self.optionalColumns = optionalColumns + self.wbs = {} + self.activities = {} + self.root_activities = [] + self.children = {} + self.roots = [] + self.relationships = {} self.resources = {} + self.extended_attributes = {} + self.hours_per_day = 8.0 + # Extra Task elements to copy onto a Pset_MSP_Task, by tag name. "all" + # as the only entry takes every tag the first task carries. + self.optionalColumns = optionalColumns self.RESOURCE_TYPES_MAPPING = {"1": "LABOR", "0": "MATERIAL", "2": None} def execute(self): self.parse_xml() - self.create_ifc() + settings = { + "work_plan": self.work_plan, + "project": self.project, + "calendars": self.calendars, + "wbs": self.wbs, + "root_activities": self.root_activities, + "activities": self.activities, + "relationships": self.relationships, + "resources": self.resources, + "children": self.children, + "roots": self.roots, + "hours_per_day": self.hours_per_day, + } + MSPIfcGenerator(self.file, self.output, settings).create_ifc() + + # -- parsing ------------------------------------------------------------ def parse_xml(self): tree = ET.parse(self.xml) project = tree.getroot() self.ns = {"pr": project.tag[1:].partition("}")[0]} - self.project["Name"] = project.findtext("pr:Name", namespaces=self.ns) or "Unnamed" - self.project["CalendarUID"] = project.findtext("pr:CalendarUID", namespaces=self.ns) or None - self.project["MinutesPerDay"] = project.findtext("pr:MinutesPerDay", namespaces=self.ns) or None - self.outline_level = 0 - self.outline_parents = {} - self.parse_task_xml(project) + + # is the file the export came from; is what the planner + # called the programme, and is the string a viewer puts in its header. + self.project["Name"] = ( + self.text(project, "Title") or self.text(project, "Name") or "Unnamed" + ) + self.project["CalendarUID"] = self.text(project, "CalendarUID") + minutes_per_day = self.text(project, "MinutesPerDay") + self.project["MinutesPerDay"] = minutes_per_day + if minutes_per_day: + self.hours_per_day = int(minutes_per_day) / 60 + self.parse_calendar_xml(project) - # TODO Doesn't do anything right now - # self.parse_resources_xml(project) + self.parse_extended_attribute_types(project) + self.parse_task_xml(project) + self.parse_resources_xml(project) - def parse_relationship_xml(self, task): - relationships = {} - id = 0 - if task.findall("pr:PredecessorLink", self.ns): - for relationship in task.findall("pr:PredecessorLink", self.ns): - relationships[id] = { - "PredecessorTask": relationship.find("pr:PredecessorUID", self.ns).text, - "Type": relationship.find("pr:Type", self.ns).text, - } - id += 1 - return relationships + def text(self, element, tag): + if element is None: + return None + return element.findtext(f"pr:{tag}", namespaces=self.ns) - def parse_task_xml(self, project): - if self.project["MinutesPerDay"]: - hours_per_day = int(self.project["MinutesPerDay"]) / 60 - else: - hours_per_day = 8 + def parse_date(self, element, tag): + """A date, or None where MS Project means "not set". - for task in project.find("pr:Tasks", self.ns): - task_id = task.find("pr:UID", self.ns).text - task_index_level = task.find("pr:OutlineLevel", self.ns).text - wbs_id = task.find("pr:WBS", self.ns).text - relationships = self.parse_relationship_xml(task) - outline_level = int(task.find("pr:OutlineLevel", self.ns).text) + Unset dates are usually omitted, but an export may carry "NA" or the + 1970-ish sentinel instead, and neither is a date anyone wants written + into a schedule. + """ + value = self.text(element, tag) + if not value or value == "NA": + return None + try: + return datetime.datetime.fromisoformat(value) + except ValueError: + return None - if outline_level != 0: - parent_task = self.tasks[self.outline_parents[outline_level - 1]] - parent_task["subtasks"].append(task_id) - self.outline_level = outline_level - self.outline_parents[outline_level] = task_id + def parse_hours(self, element, tag): + """A span MS Project states in tenths of a minute, as hours.""" + value = self.text(element, tag) + if value is None or value == "": + return None + try: + return float(value) / TENTHS_OF_A_MINUTE_PER_HOUR + except ValueError: + return None - # Microsoft Project stores durations in terms of hours. - duration = ifcopenshell.util.date.ifc2datetime(task.find("pr:Duration", self.ns).text) - hours = duration.days * 24 - hours += duration.seconds / 60 / 60 - # Let's convert it into days, where days is the appropriate hours per day - duration = timedelta(days=hours / float(hours_per_day)) + def parse_duration_hours(self, element, tag): + """A Duration element, as a number of hours. - self.tasks[task_id] = { - "Name": task.find("pr:Name", self.ns).text, - "OutlineNumber": task.find("pr:OutlineNumber", self.ns).text, - "OutlineLevel": outline_level, - "Start": datetime.datetime.fromisoformat(task.find("pr:Start", self.ns).text), - "Finish": datetime.datetime.fromisoformat(task.find("pr:Finish", self.ns).text), - "Duration": duration, - "Priority": task.find("pr:Priority", self.ns).text, - "CalendarUID": task.find("pr:CalendarUID", self.ns).text, - "PredecessorTasks": relationships if relationships else None, - "subtasks": [], - "ifc": None, - } + Written as an ISO 8601 duration whose largest unit is the hour, so + PT1976H0M0S — 247 eight hour days — rather than anything with days in + it. isodate normalises that into days and seconds regardless. + """ + value = self.text(element, tag) + if not value: + return None + try: + duration = ifcopenshell.util.date.ifc2datetime(value) + except Exception: + return None + return duration.days * 24 + duration.seconds / 3600 - # retrieve optional columns - # If first column = "all" then retrieve all columns - if len(self.optionalColumns) and self.optionalColumns[0] == "all": - self.optionalColumns = [child.tag.split("}")[1] for child in task] + def parse_extended_attribute_types(self, project): + """The custom column declarations, FieldID -> the name to file it under. - for column in self.optionalColumns: - if not self.tasks[task_id].get(column): - self.tasks[task_id][column] = ( - task.find(f"pr:{column}", self.ns).text if task.find(f"pr:{column}", self.ns) else None - ) + A planner who renames Text1 to "Zone" gets an Alias; one who does not + leaves the export saying Text1, and Text1 is then the only name there + is to use. + """ + declarations = project.find("pr:ExtendedAttributes", self.ns) + for declaration in declarations.findall("pr:ExtendedAttribute", self.ns) if declarations is not None else []: + field_id = self.text(declaration, "FieldID") + name = self.text(declaration, "Alias") or self.text(declaration, "FieldName") + if field_id and name: + self.extended_attributes[field_id] = name + + def parse_extended_attributes(self, task): + """This task's custom field values, as {name: (ifc_type, value)}. + + All of them as IfcLabel. The declaration names the field but not its + type — the type is encoded in which of MS Project's fixed slots it + occupies, Text1 or Number1 or Date1 — and these are short tags either + way, so reading them as anything else buys nothing and can only fail. + + The generator files these under the key it calls UDFs, which is P6's + word for its own version of the idea. MS Project has no user-defined + fields: it has thirty text slots, twenty number slots and so on, which + a planner may rename. Only the shared slot is borrowed — the property + set they land in is named for what they are in the file they came from. + """ + values = {} + for attribute in task.findall("pr:ExtendedAttribute", self.ns): + name = self.extended_attributes.get(self.text(attribute, "FieldID")) + value = self.text(attribute, "Value") + if name and value: + values[name] = ("IfcLabel", value) + return values def parse_calendar_xml(self, project): - def parse_working_times(day): - working_times = [] - if day.find("pr:WorkingTimes", self.ns): - for working_time in day.find("pr:WorkingTimes", self.ns).findall("pr:WorkingTime", self.ns): - if working_time.find("pr:FromTime", self.ns) is None: - continue - working_times.append( - { - "Start": datetime.time.fromisoformat(working_time.find("pr:FromTime", self.ns).text), - "Finish": datetime.time.fromisoformat(working_time.find("pr:ToTime", self.ns).text), - } - ) - return working_times - - def parse_exception(exception): - work_times = parse_working_times(exception) - time_period = exception.find("pr:TimePeriod", self.ns) - data = { - "Name": ( - exception.find("pr:Name", self.ns).text if exception.find("pr:Name", self.ns) is not None else None - ), - "FromDate": ( - datetime.datetime.fromisoformat(time_period.find("pr:FromDate", self.ns).text) - if time_period is not None - else None - ), - "ToDate": ( - datetime.datetime.fromisoformat(time_period.find("pr:ToDate", self.ns).text) - if time_period is not None - else None - ), - "Occurrences": ( - int(exception.find("pr:Occurrences", self.ns).text) - if exception.find("pr:Occurrences", self.ns) is not None - else None - ), - "Month": ( - exception.find("pr:Month", self.ns).text - if exception.find("pr:Month", self.ns) is not None - else None - ), - "MonthDay": ( - exception.find("pr:MonthDay", self.ns).text - if exception.find("pr:MonthDay", self.ns) is not None - else None - ), - "Type": ( - exception.find("pr:Type", self.ns).text if exception.find("pr:Type", self.ns) is not None else None - ), - "WorkingTimes": work_times, - "ifc": None, - } - return data - - for calendar in project.find("pr:Calendars", self.ns).findall("pr:Calendar", self.ns): - calendar_id = calendar.find("pr:UID", self.ns).text - week_days = [] - exceptions = [] - week_days_element = calendar.find("pr:WeekDays", self.ns) - week_day_elements = week_days_element.findall("pr:WeekDay", self.ns) if week_days_element else [] - for week_day in week_day_elements: - if week_day.find("pr:WorkingTimes", self.ns): - if week_day.find("pr:DayType", self.ns).text == "0": - data = parse_exception(week_day) - data["Type"] = "2" - exceptions.append(data) - else: - week_days.append( - { - "DayType": week_day.find("pr:DayType", self.ns).text, - "WorkingTimes": parse_working_times(week_day), - "ifc": None, - } - ) - exceptions_element = calendar.find("pr:Exceptions", self.ns) - for exception in exceptions_element.findall("pr:Exception", self.ns) if exceptions_element else []: - data = parse_exception(exception) - exceptions.append(data) - + calendars = project.find("pr:Calendars", self.ns) + for calendar in calendars.findall("pr:Calendar", self.ns) if calendars is not None else []: + calendar_id = self.text(calendar, "UID") self.calendars[calendar_id] = { - "Name": calendar.find("pr:Name", self.ns).text, - "StandardWorkWeek": week_days, - "HolidayOrExceptions": exceptions, + "Name": self.text(calendar, "Name"), + "Type": "Base" if self.text(calendar, "IsBaseCalendar") == "1" else "Derived", + "HoursPerDay": self.hours_per_day, + "BaseCalendarUID": self.text(calendar, "BaseCalendarUID"), + "StandardWorkWeek": self.parse_working_week(calendar), + "HolidayOrExceptions": self.parse_exceptions(calendar), + } + self.inherit_base_calendars() + + def parse_working_week(self, calendar): + week = [] + week_days = calendar.find("pr:WeekDays", self.ns) + for week_day in week_days.findall("pr:WeekDay", self.ns) if week_days is not None else []: + day_of_week = DAY_TYPES.get(self.text(week_day, "DayType")) + # DayType 0 is not a day of the week at all, it is a date range + # exception filed in the same list. It is read with the exceptions. + if day_of_week is None: + continue + week.append( + { + "DayOfWeek": day_of_week, + "WorkTimes": self.parse_working_times(week_day), + "ifc": None, + } + ) + return week + + def parse_working_times(self, element): + times = [] + working_times = element.find("pr:WorkingTimes", self.ns) + for working_time in working_times.findall("pr:WorkingTime", self.ns) if working_times is not None else []: + start = self.text(working_time, "FromTime") + finish = self.text(working_time, "ToTime") + if not start or not finish: + continue + times.append( + { + "Start": datetime.time.fromisoformat(start), + "Finish": datetime.time.fromisoformat(finish), + } + ) + return times + + def parse_exceptions(self, calendar): + """Named days that do not follow the working week, keyed year > month. + + MS Project states these twice in the same file: once as an <Exception>, + and once as a <WeekDay> of DayType 0 carrying the same period, which is + how the format did it before <Exceptions> existed. Reading both would + write every holiday into the calendar twice, so the older spelling is + read only where the newer one is absent. + """ + exceptions = {} + exceptions_element = calendar.find("pr:Exceptions", self.ns) + elements = exceptions_element.findall("pr:Exception", self.ns) if exceptions_element is not None else [] + if not elements: + week_days = calendar.find("pr:WeekDays", self.ns) + elements = [ + week_day + for week_day in (week_days.findall("pr:WeekDay", self.ns) if week_days is not None else []) + if self.text(week_day, "DayType") == "0" + ] + + for element in elements: + for day in self.exception_dates(element): + month = exceptions.setdefault(day.year, {}).setdefault(day.month, {}) + month.setdefault("FullDay", []) + month.setdefault("WorkTime", []) + work_times = self.parse_working_times(element) + if work_times: + month["WorkTime"].append({"Day": day.day, "WorkTimes": work_times, "ifc": None}) + else: + month["FullDay"].append(day.day) + return exceptions + + def exception_dates(self, element): + """The dates one exception actually covers. + + A one-off exception states the period it applies to and is simply that + run of days. A recurring one states the period it recurs *within* — a + public holiday declared yearly spans a decade — and picking the days + out of that needs MS Project's recurrence rules, which its own + documentation does not give. The two are told apart by Occurrences: a + recurrence has fewer of them than the span has days. + + A recurring exception is skipped rather than guessed at. Guessing wrong + marks working days as holidays, and a calendar that is wrong in that + direction quietly moves every date computed from it. + """ + period = element.find("pr:TimePeriod", self.ns) + start = self.parse_date(period, "FromDate") + finish = self.parse_date(period, "ToDate") + if start is None: + return [] + if finish is None or finish < start: + finish = start + days = (finish.date() - start.date()).days + 1 + + occurrences = self.text(element, "Occurrences") + if occurrences and int(occurrences) < days: + print( + f"note: skipping recurring calendar exception " + f"{self.text(element, 'Name') or start.date()} — {occurrences} occurrences " + f"over {days} days, and the recurrence is not readable from the export" + ) + return [] + return [start.date() + datetime.timedelta(days=offset) for offset in range(days)] + + def inherit_base_calendars(self): + """A calendar with no week of its own works its base calendar's week. + + MS Project derives resource and task calendars from a base and stores + only the differences. An IfcWorkCalendar has no such notion, so what + was inherited is written out in full. + """ + for calendar in self.calendars.values(): + seen = set() + base = calendar + while not base["StandardWorkWeek"] and base["BaseCalendarUID"] not in seen: + seen.add(base["BaseCalendarUID"]) + base = self.calendars.get(base["BaseCalendarUID"]) + if base is None: + break + calendar["StandardWorkWeek"] = [dict(day, ifc=None) for day in base["StandardWorkWeek"]] + if not calendar["HolidayOrExceptions"]: + calendar["HolidayOrExceptions"] = base["HolidayOrExceptions"] + + def parse_task_xml(self, project): + tasks = project.find("pr:Tasks", self.ns) + elements = [ + task + for task in (tasks.findall("pr:Task", self.ns) if tasks is not None else []) + # A null task is a blank row a planner left in the grid. It has no + # name, no dates and no meaning outside MS Project's own display. + if self.text(task, "IsNull") != "1" + ] + + # MS Project's tree is the OutlineLevel column and nothing else: a task + # belongs to the nearest row above it at a shallower level. Levels are + # normally 1-based, with 0 used only where the project summary task is + # exported, so the root is whatever level the file happens to start at + # rather than a number that can be assumed. + ancestors = [] + parents = {} + for task in elements: + uid = self.text(task, "UID") + level = int(self.text(task, "OutlineLevel") or 0) + while ancestors and ancestors[-1][1] >= level: + ancestors.pop() + parent = ancestors[-1][0] if ancestors else None + parents[uid] = parent + self.children.setdefault(parent, []).append(uid) + ancestors.append((uid, level)) + self.roots = self.children.pop(None, []) + + for task in elements: + uid = self.text(task, "UID") + if self.children.get(uid): + self.add_wbs(task, uid, parents[uid]) + else: + self.add_activity(task, uid, parents[uid]) + + self.parse_relationship_xml(elements) + + def add_wbs(self, task, uid, parent): + """A summary task: a branch of the outline, with no work of its own. + + Its dates are MS Project's roll-up of its children rather than anything + a planner entered, so no IfcTaskTime is written. A reader that wants + the span of a branch takes it from the leaves, which is where the + planning actually happened. + """ + self.wbs[uid] = { + "Name": self.text(task, "Name") or "", + "Code": self.text(task, "OutlineNumber") or uid, + "ParentObjectId": parent, + "SequenceNumber": 0, + "UDFs": self.parse_extended_attributes(task), + "ifc": None, + "rel": None, + # Only the leaves directly under this node. The generator's own + # create_tasks reads this; MSPIfcGenerator walks self.children + # instead, so that summaries keep their place among them. + "activities": [child for child in self.children.get(uid, []) if not self.children.get(child)], + } + + def add_activity(self, task, uid, parent): + start = self.parse_date(task, "Start") + finish = self.parse_date(task, "Finish") + percent_complete = self.text(task, "PercentComplete") + actual_start = self.parse_date(task, "ActualStart") + actual_finish = self.parse_date(task, "ActualFinish") + + calendar_id = self.text(task, "CalendarUID") + # -1 is MS Project for "no calendar of its own". + if calendar_id in (None, "", "-1") or calendar_id not in self.calendars: + calendar_id = self.default_calendar_id() + + if parent is None: + self.root_activities.append(uid) + + self.activities[uid] = { + "Name": self.text(task, "Name") or "", + "Identification": self.text(task, "OutlineNumber") or uid, + "StartDate": start, + "FinishDate": finish, + "PlannedDuration": self.parse_duration_hours(task, "Duration"), + "Status": self.status(percent_complete, actual_start, actual_finish), + "CalendarObjectId": calendar_id, + "IsMilestone": self.text(task, "Milestone") == "1" or start == finish, + "UDFs": self.parse_extended_attributes(task), + "Codes": {}, + "ActualStartDate": actual_start, + "ActualFinishDate": actual_finish, + "EarlyStartDate": self.parse_date(task, "EarlyStart"), + "EarlyFinishDate": self.parse_date(task, "EarlyFinish"), + "LateStartDate": self.parse_date(task, "LateStart"), + "LateFinishDate": self.parse_date(task, "LateFinish"), + "TotalFloat": self.parse_hours(task, "TotalSlack"), + "FreeFloat": self.parse_hours(task, "FreeSlack"), + # IfcTaskTime.Completion is a ratio; MS Project states a percentage. + "PercentComplete": float(percent_complete) / 100 if percent_complete else None, + "ifc": None, + } + + if self.optionalColumns: + if self.optionalColumns[0] == "all": + self.optionalColumns = [child.tag.partition("}")[2] for child in task] + self.activities[uid]["OptionalColumns"] = { + column: self.text(task, column) for column in self.optionalColumns if self.text(task, column) } - def create_ifc(self): - if not self.file: - self.create_boilerplate_ifc() - if not self.work_plan: - self.work_plan = ifcopenshell.api.sequence.add_work_plan(self.file) - work_schedule = self.create_work_schedule() - self.create_calendars() - self.create_tasks(work_schedule) - self.create_rel_sequences() + def status(self, percent_complete, actual_start, actual_finish): + """P6's three words for how far along a task is. - def create_boilerplate_ifc(self): - self.file = ifcopenshell.file(schema="IFC4") - ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") - self.work_plan = self.file.create_entity("IfcWorkPlan") + MS Project has no status column — it has a percentage and, when the + export carries them, the actual dates. The same three words come out + either way, so a reader does not have to know which tool the schedule + was planned in. + """ + percent = float(percent_complete) if percent_complete else 0 + if actual_finish is not None or percent >= 100: + return "Completed" + if actual_start is not None or percent > 0: + return "In Progress" + return "Not Started" - def create_tasks(self, work_schedule): - for task_id in self.tasks: - task = self.tasks[task_id] - # Outline Level can be None or 0 - if not task["OutlineLevel"]: - self.create_task(task, work_schedule=work_schedule) + def default_calendar_id(self): + if self.project["CalendarUID"] in self.calendars: + return self.project["CalendarUID"] + return next(iter(self.calendars), None) - def create_work_schedule(self): - return ifcopenshell.api.sequence.add_work_schedule( - self.file, name=self.project["Name"], work_plan=self.work_plan - ) - - def create_calendars(self): - def has_work_or_exceptions(calendar): - return calendar["StandardWorkWeek"] or calendar["HolidayOrExceptions"] - - for calendar in self.calendars.values(): - if not has_work_or_exceptions(calendar): - continue - calendar["ifc"] = ifcopenshell.api.sequence.add_work_calendar(self.file, name=calendar["Name"]) - self.process_working_week(calendar["StandardWorkWeek"], calendar["ifc"]) - self.process_exceptions(calendar["HolidayOrExceptions"], calendar["ifc"]) - - def create_task(self, task, work_schedule=None, parent_task=None): - task["ifc"] = ifcopenshell.api.sequence.add_task( - self.file, - work_schedule=work_schedule if work_schedule else None, - parent_task=parent_task["ifc"] if parent_task else None, - ) - - calendar = None - if task["CalendarUID"] != "-1": - calendar = self.calendars[task["CalendarUID"]]["ifc"] - elif not parent_task and self.project["CalendarUID"]: - calendar = self.calendars[self.project["CalendarUID"]]["ifc"] - - if calendar: - ifcopenshell.api.control.assign_control( - self.file, - relating_control=calendar, - related_objects=[task["ifc"]], - ) - - ifcopenshell.api.sequence.edit_task( - self.file, - task=task["ifc"], - attributes={ - "Name": task["Name"], - "Identification": task["OutlineNumber"], - "IsMilestone": task["Start"] == task["Finish"], - }, - ) - task_time = ifcopenshell.api.sequence.add_task_time(self.file, task=task["ifc"]) - ifcopenshell.api.sequence.edit_task_time( - self.file, - task_time=task_time, - attributes={ - "ScheduleStart": task["Start"], - "ScheduleFinish": task["Finish"], - "DurationType": "WORKTIME" if task["Duration"] else None, - "ScheduleDuration": task["Duration"] if task["Duration"] else None, - }, - ) - for subtask_id in task["subtasks"]: - self.create_task(self.tasks[subtask_id], parent_task=task) - - # create pset for optional columns - if len(self.optionalColumns): - pset = ifcopenshell.api.pset.add_pset(self.file, product=task["ifc"], name="Pset_MSP_Task") - - ifcopenshell.api.pset.edit_pset( - self.file, - pset=pset, - properties={name: str(task[name]) for name in self.optionalColumns if task[name]}, - ) - - def process_working_week(self, week, calendar): - day_map = { - "1": 7, # Sunday - "2": 1, # Monday - "3": 2, # Tuesday - "4": 3, # Wednesday - "5": 4, # Thursday - "6": 5, # Friday - "7": 6, # Saturday - "0": 0, # Exception - } - for day in week: - if day["ifc"]: - continue - - day["ifc"] = ifcopenshell.api.sequence.add_work_time( - self.file, work_calendar=calendar, time_type="WorkingTimes" - ) - - weekday_component = [day_map[day["DayType"]]] - for day2 in week: - if day["DayType"] == day2["DayType"]: + def parse_relationship_xml(self, elements): + for task in elements: + successor = self.text(task, "UID") + for index, link in enumerate(task.findall("pr:PredecessorLink", self.ns)): + predecessor = self.text(link, "PredecessorUID") + if predecessor is None: continue - if day["WorkingTimes"] == day2["WorkingTimes"]: - weekday_component.append(day_map[day2["DayType"]]) - # Don't process the next day, as we can group it - day2["ifc"] = day["ifc"] + self.relationships[f"{successor}-{index}"] = { + "PredecessorActivity": predecessor, + "SuccessorActivity": successor, + "Type": SEQUENCE_TYPES.get(self.text(link, "Type"), "FINISH_START"), + "Lag": self.parse_lag(link), + } - work_time_name = "Weekdays: {}".format(", ".join([str(c) for c in sorted(weekday_component)])) - ifcopenshell.api.sequence.edit_work_time( - self.file, - work_time=day["ifc"], - attributes={"Name": work_time_name}, - ) - - recurrence = ifcopenshell.api.sequence.assign_recurrence_pattern( - self.file, parent=day["ifc"], recurrence_type="WEEKLY" - ) - ifcopenshell.api.sequence.edit_recurrence_pattern( - self.file, - recurrence_pattern=recurrence, - attributes={"WeekdayComponent": weekday_component}, - ) - for work_time in day["WorkingTimes"]: - ifcopenshell.api.sequence.add_time_period( - self.file, - recurrence_pattern=recurrence, - start_time=work_time["Start"], - end_time=work_time["Finish"], - ) - - def create_rel_sequences(self): - self.sequence_type_map = { - "0": "FINISH_FINISH", - "1": "FINISH_START", - "2": "START_FINISH", - "3": "START_START", - } - for task in self.tasks.values(): - if not task["PredecessorTasks"]: - continue - for predecessor in task["PredecessorTasks"].values(): - rel_sequence = ifcopenshell.api.sequence.assign_sequence( - self.file, - related_process=task["ifc"], - relating_process=self.tasks[predecessor["PredecessorTask"]]["ifc"], - ) - if predecessor["Type"]: - ifcopenshell.api.sequence.edit_sequence( - self.file, - rel_sequence=rel_sequence, - attributes={"SequenceType": self.sequence_type_map[predecessor["Type"]]}, - ) + def parse_lag(self, link): + if self.text(link, "LagFormat") in PERCENT_LAG_FORMATS: + return 0 + return self.parse_hours(link, "LinkLag") or 0 def parse_resources_xml(self, project): - resources_lst = project.find("pr:Resources", self.ns) - resources = resources_lst.findall("pr:Resource", self.ns) - for resource in resources: - name = resource.find("pr:Name", self.ns) - id = resource.find("pr:ID", self.ns).text - if name is not None: - name = name.text - else: - name = None - self.resources[id] = { + resources = project.find("pr:Resources", self.ns) + for resource in resources.findall("pr:Resource", self.ns) if resources is not None else []: + name = self.text(resource, "Name") + if not name: + continue + self.resources[self.text(resource, "UID")] = { "Name": name, - "Code": resource.find("pr:UID", self.ns).text, + "Code": self.text(resource, "ID"), "ParentObjectId": None, - "Type": self.RESOURCE_TYPES_MAPPING[resource.find("pr:Type", self.ns).text], + "Type": self.RESOURCE_TYPES_MAPPING.get(self.text(resource, "Type")), "ifc": None, "rel": None, } - - def process_exceptions(self, exceptions, calendar): - for exception in exceptions or []: - self.process_exception(exception, calendar) - - def process_exception(self, exception, calendar): - if exception["ifc"] or not exception["FromDate"]: - return - exception["ifc"] = ifcopenshell.api.sequence.add_work_time( - self.file, work_calendar=calendar, time_type="ExceptionTimes" - ) - ifcopenshell.api.sequence.edit_work_time( - self.file, - work_time=exception["ifc"], - attributes={ - "Name": exception["Name"], - "Start": ifcopenshell.util.date.datetime2ifc(exception["FromDate"], "IfcDate"), - "Finish": ifcopenshell.util.date.datetime2ifc(exception["ToDate"], "IfcDate"), - }, - ) - # BIG assumptions due to missing types enumeration in docs https://learn.microsoft.com/en-us/office-project/xml-data-interchange/exception-element?view=project-client-2016 - recurrence_type = None - if exception["Type"] == "1": - recurrence_type = "DAILY" - attributes = { - "Occurrences": int(exception["Occurrences"]) if exception["Occurrences"] else None, - } - elif exception["Type"] == "2": - recurrence_type = "YEARLY_BY_DAY_OF_MONTH" - month_component = [int(exception["Month"]) + 1] if exception["Month"] else None - day_component = [int(exception["MonthDay"])] if exception["MonthDay"] else None - if month_component is None and (exception["FromDate"].date().day == exception["ToDate"].date().day): - month_component = [exception["FromDate"].date().month] - day_component = [exception["FromDate"].date().day] - attributes = { - "MonthComponent": month_component, - "DayComponent": day_component, - "Occurrences": int(exception["Occurrences"]) if exception["Occurrences"] else None, - } - else: - return - recurrence = ifcopenshell.api.sequence.assign_recurrence_pattern( - self.file, - parent=exception["ifc"], - recurrence_type=recurrence_type, - ) - ifcopenshell.api.sequence.edit_recurrence_pattern( - self.file, recurrence_pattern=recurrence, attributes=attributes - ) - for work_time in exception["WorkingTimes"] or []: - ifcopenshell.api.sequence.add_time_period( - self.file, - recurrence_pattern=recurrence, - start_time=work_time["Start"], - end_time=work_time["Finish"], - )