Featured Artikel

Einen Netzplan erstellen

In diesem Beitrag stelle ich ein kleines Python-Programm vor, mit dem sich ein Netzplan „durchrechnen“ lässt. Netzpläne sind ein beliebtes Planungswerkzeug im Bereich des Projektmanagement. Es gibt „jede Menge“ Beschreibungen und Beispiele im Internet. Eine Webseite, auf der das Erstellen eines Netzplans besonders anschaulich beschrieben wird (so, dass sogar ich es verstanden habe;) ist

*** Der Beitragt ist aktuell nur ein ENTWURF – es fehlen noch einige Erläuterungen und der Text enthält sicher noch einige Rechtschreibfehler usw. das Programm NetzplanV1.py funktioniert aber ***

Netzpläne in Theorie und Praxis in drei Folgen

Eine weitere, etwas kompaktere, aber ebenfalls hervorragend illustrierte Erklärung gibt es unter der folgenden Adresse:

Netzplantechnik: Eine Schritt-für-Schritt-Anleitung mit Beispiel

Auch wenn das Thema Netzplantechnik nicht nur unter den genannten Webseiten, sondern an vielen anderen Stellen im Web und mit Sicherheit in jedem Fachbuch zum Thema Projektmanagement beschrieben wird, ein paar Stichworte von mir, damit ihr das folgende Python-Programm besser verstehen könnt.

Der Sinn und Zweck eines Netzplan ist es, die Projektdauer eines aus mehreren Vorgängen bestehenden Projekts besser beurteilen zu können und zu erkennen, welche Vorgänge einen zeitlichen Puffer besitzen und welche Vorgänge „kritisch“ sind, da sie keinen Puffer besitzen.

Ein Netzplan enthält alle Vorgänge (also abgeschlossene Teilaufgaben) eines Projekts. Jeder Vorgang wird durch einen Kasten dargestellt.

Die Kästen werden durch Pfeile verbunden. Ein Vorgang kann beliebig viele Vorgänger und Nachfolger besitzen. Ausnahmen sind natürlich die Vorgänge am Anfang und am Ende, die keine Vorgänger bzw. Nachfolger besitzen.

Jeder Vorgangskasten enthält genau acht Angaben:

>Eine Bezeichnung oder nur eine Nummer oder einen Buchstaben

>Eine Dauer in einer Zeiteinheit

>Einen frühesten Anfangszeitpunkt (FAZ)

>EInen frühesten Endzeitpunkt (FEZ)

>Einen spätesten Anfangszeitpunkt (SAZ)

>Einen spätesten Endzeitpunkt (SEZ)

>Einen Gesamtpuffer (GP)

>Einen freien Puffer (FP)

GP und FP sind ebenfalls Zeiteinheiten.

Die wichtigste Aufgabe eines Netzplans ist es, die Pufferzeiten zu erhalten, da sich anhand dieser Zahlen erkennen lässt, wo es Spielraum für Verzögerungen gibt, ohne dass dadurch der Fertigstellungszeitpunkt verschoben werden muss.

Eine weitere wichtige Kenngröße ist der kritische Pfad. Er umfasst alle Vorgänge, die keinen Zeitpuffer aufweisen, bei denen es also keinen Spielraum für eine Verschiebung gibt.

EIn Netzplan wird in vier Schritten erstellt:

Schritt 1: Aufschreiben aller Vorgänge als Kästen und Eintragen der Verbindungspfeile und der Projektdauer.

Dieser Schritt ist sehr einfach und bedarf keiner Erklärungen. Eventuell muss man die einzelnen Vorgänge und ihre Beziehung zueinander aus einer Aufgabenbeschreibung „herauslesen“ (falls man das Fach „Einführung in das Projektmanagement“ aus irgendeinem Grund absolvieren muss).

Schritt 2: Eintragen der Werte für FAZ und FEZ.

Das sog. Vorwärtsrechnen. In einer rein linearen Folge von Vorgängen beginnt der FAZ des ersten Vorgangs (bzw. allgemein dem oder die Vorgänge ohne einen Vorgänger) bei 0, der FEZ ist immer FAZ + Dauer usw.

Werden zwei oder mehr Schritte parallel ausgeführt, ist der FAZ des Teilschrittes, der die parallel ausgeführten Schritte wieder zusammenführt, der größte FEZ-Wert aller Teilschritte.

Schritt 3: Eintragen der Werte für SAZ und SEZ.

Das sog. Rückwärtsrechnen, das immer mit dem letzten Vorgang beginnt. Konkret, es beginnt mit dem letzten Vorgang ohne einen Nachfolger (davon kann es auch mehrere geben). Beim „ersten“ Vorgang (in der umgekehrten Reihenfolge betrachtet), sind FEZ und SEZ identisch. Der SAZ-Wert ergibt sich aus der Differenz von SEZ und Dauer. Bei allen anderen Teilschritten entspricht der SEZ-Wert dem SAZ-Wert des „Vorgängers“ (immer bezogen auf die umgekehrte Reihenfolge). Gibt es mehrere Vorgänger, wird der kleinste Wert genommen.

Schritt 4: Berechnen des Gesamtpuffers (GP) für jeden Vorgang

Sind die SAZ-Werte bekannt, kann der Gesamtpuffer jedes Vorgangs aus der Differenz GP = SAZ – FAZ berechnet werden. Der freie Puffer FP ergibt sich aus der Differenz des FAZ-Wertes des Nachfolgers und des FAZ-Wertes. Gibt es mehrere FAZ-Werte, wird der kleinste Wert genommen.

Wer jetzt noch dabei ist, Respekt, den wer nicht gerade Projektmanagement im Rahmen einer Ausbildung lernen muss, wird das Thema maximal am Rande interessieren.

Der beschriebene Ablauf „schreitt gerade zu nach einer Umsetzung in der Programmiersprache Deiner Wahl. In diesem Blog ist das natürlich Python.

Gleich vorweg. Das Python-Programm, das im Folgenden vorgestellt wird, ist eine schlichte Konsolenanwendung. Die Daten des Netzplanes werden aus einer Textdatei eingelesen und die Werte werden nur in der Konsole ausgegeben. Viel schöner wäre es natürlich, wenn man richtige Kästen auf einer Fläche platzieren und die Eckdaten einfach eintragen könnte. So etwas zu programmieren ist natürlich deutlich aufwändiger. Ich würde eine solche Anwendung als Webapp oder Smartphone-App umsetzen (und dann nicht unbedingt Python verwenden). Oder gleich den Netzplan auf draw.io zeichnen, das Ganze ausdrucken und die Zahlen mit einem Stift eintragen.

Das Python-Programm, das im Folgenden vorgestellt wird, ist in erster Linie eine hervorragende „Programmierer-Challenge“, eine sehr gute Übung und ein schönes Anschauungsbeispiel für eine Programmiersprache, bei der das Programmieren einfach Spaß macht.

Das Thema Dateizugriff kommt vor, es ist eine Klasse im Spiel, Objekte werden in einem Dictionary abgelegt und u.a. kommt auch eine Prise List Comprehension ins Spiel. Also alles das, was ein Python-Programm ausmacht. Das Programm kann sowohl als Modul als auch als reguläres Programm verwendet werden.

Umgesetzt habe ich alles mit Visual Studio Code, mit dem ich gerne arbeite. Jeder verwendet natürlich den Editor oder die IDE seiner Wahl.

Wer sich das Abtippen ersparen möchte, findet die Programmdatei in meinem GitHup-Repo zusammen mit Beispieldateien für Netzpläne und einem weiteren Python-Programm, welches das Netzplan-Programm als Modul einbindet und alle Txt-Dateien im aktuellen Verzeichnis der Reihe nach umsetzt.

https://github.com/pemo11/pynetzplan

Dazu der übliche „Disclaimer“. Das kleine Programm versteht sich in erster Linie als Übungsbeispiel für das Erlernen der Python-Programmierung und der Netzplantechnik im Allgemeinen (z.B. für den Fall, dass Thema in einer Prüfung ein Thema ist). Es ist (natürlich) nicht für den produktiven Einsatz gedacht.

Schritt 1: Die Formalismen

Ein Python-Programm beginnt in der Regel mit einer Kommentarzeile und diversen import-Befehlen. Die unter Linux übliche „shebang“-Zeile kann unter Windows entfallen. Persönlich halte ich diese Zeile auch unter Linux für entbehrlich, da man Python-Programme auch unter Linux über den Python-Interpreter startet (auf der anderen Seite habe ich, wenn ich ehrlich bin, von Linux nicht wirklich eine Ahnung;).

#!/usr/bin/env python3
# Pufferzeiten bei einem Netzplan berechnen
import os
import sys

Schritt 2: Die Klasse Vorgang

Jeder Vorgang wird durch die Klasse Vorgang repräsentiert, die sich in erster Linie durch ihre Atribute auszeichnet. Sie spielt später aber nur beim Einlesen der Textdatei eine Rolle.

# Repräsentiert einen Vorgang
class Vorgang:

    def __init__(self, Name, Beschreibung, Dauer, Vorgaenger, Nachfolger):
        self.Name = Name
        self.Beschreibung = Beschreibung
        self.Dauer = int(Dauer)
        self.Vorgaenger = Vorgaenger
        self.AnzahlVorgaenger = 0 if Vorgaenger[0] == "" else len(Vorgaenger)
        self.Nachfolger = Nachfolger
        self.AnzahlNachfolger = 0 if Nachfolger[0] == "" else len(Nachfolger)
        self.FAZ = 0
        self.SAZ = 0
        self.FEZ = 0
        self.SEZ = 0
        self.GP = 0
        self.FP = 0

    def __toString__(self):
        if len(self.Beschreibung) == 0:
            return f"{self.Name}: Dauer: { self.Dauer} FAZ/FEZ: {self.FAZ}/{self.FEZ} SAZ/SEZ: {self.SAZ}/{self.SEZ} GP/FP: {self.GP}/{self.FP}"
        else:
            return f"{self.Beschreibung}: Dauer: { self.Dauer} FAZ/FEZ: {self.FAZ}/{self.FEZ} SAZ/SEZ: {self.SAZ}/{self.SEZ} GP/FP: {self.GP}/{self.FP}"


Schritt 3: Die Function ErstelleNetzplan

Da die Programmdatei Netzplan.py auch als Modul in ein anderes Programm einbindbar sein soll, enthält sie eine Function mit dem Namen ErstelleNetzplan, die im weiteren Verlauf des Programms immer dann automatisch ausgeführt wird, wenn das Programm direkt gestartet wurde. Die Function ist relativ umfangreich. Ihr wird der Pfad einer Textdatei übergeben, in der alle Aufgaben definiert sind (mehr dazu in Kürze).

Ich werde sie in naher Zukunft ausführlicher beschreiben, da sie das „Herzstück“ des kleinen Programms ist.

def ErstelleNetzplan(Dateipfad):

    print(f"*** Verarbeite {Dateipfad} ***")

    netzplan = {}

    with open(Dateipfad, encoding="Utf-8") as fh:
        for zeile in fh:
            if zeile == "":
                break
            if zeile[-1] == "\n":
                zeile = zeile[:-1]
            name, beschreibung, dauer, vor, nach =  zeile.split(",")
            # Es soll immer eine Liste der Vorgänger und Nachfolger gebildet werden
            vor = vor.split(":")
            nach = nach.split(":")
            netzplan[name] = Vorgang(name,beschreibung,dauer,vor,nach)

    # Schritt 1: FAZ und FEZ im Vorwärtsgang berechnen
    for pk in netzplan:
        p = netzplan[pk]
        # Gibt es mehrere Vorgänger?
        if p.AnzahlVorgaenger == 1:
            v = netzplan[p.Vorgaenger[0]]
            p.FAZ = v.FEZ
        elif p.AnzahlVorgaenger > 1:
            maxFEZ = max([netzplan[pv].FEZ for pv in p.Vorgaenger])
            p.FAZ = maxFEZ
        else:
            p.FAZ = 0
            p.FEZ = p.FAZ + p.Dauer

    # Schritt 2: SAZ und SEZ im Rückwärtsgang berechnen
    # Und bei der Gelegenheit auch GP und FP
    pKeys = list(netzplan.keys())
    pKeys.reverse()

    for pk in pKeys:
        p = netzplan[pk]
        # if len(p.Nachfolger) == 0:
        if p.AnzahlNachfolger == 0:
            # Weitere Randbedingung - es kann mehrere Vorgänger ohne Nachfolger geben,
            # es muss daher der größte FEZ genommen werden
            maxFEZ = max([netzplan[pn].FEZ for pn in pKeys if netzplan[pn].AnzahlNachfolger == 0])
            p.SEZ = maxFEZ
        elif p.AnzahlNachfolger == 1:
            nv = netzplan[p.Nachfolger[0]]
            p.SEZ = nv.SAZ
            p.FP = nv.FAZ - p.FEZ
        else:
            minSAZ = min([netzplan[pn].SAZ for pn in p.Nachfolger])
            minFAZ = min([netzplan[pn].FAZ for pn in p.Nachfolger])
            p.SEZ = minSAZ
            p.FP = minFAZ - p.FEZ
            p.SAZ = p.SEZ - p.Dauer
            p.GP = p.SAZ - p.FAZ

    for p in netzplan.values():
        print(p.__toString__())

    # Schritt 3: Kritischen Pfad ermitteln
    # Also alle Vorgänge, bei denen GP und FP 0 sind
    kritischerPfad = []
    # Das erste Element holen
    # Weitere Randbedingung  - gibt es mehrere Vorgänge ohne Vorgänger, hole den mit GP=0
    # p = list(netzplan.values())[0]
    p = [a for a in netzplan.values() if a.AnzahlVorgaenger == 0][0]
    kritischerPfad.append(p.Beschreibung if len(p.Beschreibung) > 0 else p.Name)
    while p.AnzahlNachfolger > 0:
        # Gibt es einen Nachfolger und sind GP und FP gleich 0?
        if p.AnzahlNachfolger == 1 and p.GP == 0 and p.FP == 0:
            p = netzplan[p.Nachfolger[0]]
            kritischerPfad.append(p.Beschreibung if len(p.Beschreibung) > 0 else p.Name)
        else:
            l = [netzplan[np] for np in p.Nachfolger if netzplan[np].GP == 0 and netzplan[np].FP == 0]
            if len(l) == 0:
                print("*** Es kann kein kritischer Pfad gebildet werden! ***")
                break
            p = l[0]
            kritischerPfad.append(p.Beschreibung if len(p.Beschreibung) > 0 else p.Name)
 
    if len(kritischerPfad) > 1:
        print("Der kritische Pfad:",end=" ")
        print(",".join(kritischerPfad))

Schritt 4: Das Finale

Das „Finale“ des Programms besteht in der üblichen Abfrage der Variablen __Name__ auf den Wert „__Main__„. Besitzt diese Variable diesen Wert bedeutet es, dass das Python-Programm direkt ausgeführt wurde. In diesem Fall wird die Function ErstelleNetzplan mit dem Argument, das beim Aufruf des Programms übergeben wurde, als Parameterwert.

# Prüfen, ob Datei als Modul ausgeführt wird
if __name__ == "__main__":
    if len(sys.argv) == 1:
        print("!!! Aufruf: Netzplan.py Dateiname")
        exit(1)
    dateiPfad = sys.argv[1]
    # nur bei VS Code erforderlich
    dateiPfad = os.path.join(os.path.dirname(__file__), dateiPfad)
    ErstelleNetzplan(dateiPfad)

Der Aufruf für einen ersten Test

Bliebe noch zu klären. wie das kleine Programm aufgerufen wird. Ausgangspunkt ist eine Textdatei mit einer Definition aller Aufgaben. Jede Aufgabe wird durch eine Zeile definiert. Jede Zeile enthält genau 5 Angaben:

>Ein Name (Zahl oder Buchstabe)

>Eine optionale Beschreibung des Vorgangs

>Die Dauer des Vorgangs

>Die Namen der Vorgänger. Gibt es mehrere Vorgänger, werden diese durch einen Doppelpunkt getrennt

>Die Namen der Nachfolger. Gibt es mehrere Nachfolger, werden diese durch einen Doppelpunkt getrennt

Gibt es keine Vorgänger oder Nachfolger, bleibt die Spalte einfach leer.

Eine Zeile kann damit wie folgt aufgebaut sein:

1,Online-Umfrage,12,,3

oder

1,,4,,2

Die vollständige Definitionsdatei für einen Netzplan kann daher wie folgt aussehen:

1,Online-Umfrage,12,,3
2,Experteninterview,8,,3
3,Auswertung,4,1:2,5:6
4,Kostenplan aufstellen,10,,6:7
5,Prototyp-Ausschreibung,8,3,8
6,Konzept erstellen,6,3:4,8
7,Finanzierungsplan,10,4,10
8,Prototyp entwickeln,12,5:6,9
9,Test mit Bürgern,4,8,10:11
10,Werbematerial erstellen,4,7:9,
11,Beschlussvorlage erstellen,6,9,

Befindet sich der Netzplan in der Datei Np01.txt, sieht der Aufruf des Python-Programms wie folgt aus:

python netzplan.py Np01.txt

Anschließend werden zu jedem Vorgang die Werte für FAZ/SAZ, FEZ/SEZ und GP/FP in der Konsole ausgegeben.

Natürlich wäre es schön, wenn ein richtiger Netzplan z.B. als Bitmap entstehen würde. Theoretisch wäre der Aufwand nicht allzu groß, wenn der Output in SVG konvertiert werden würde. Und wie erzeugt man SVG-XML per Python? Zum Beispiel mit dem Modul SVGWrite. Wenn ich in den nächsten Wochen einmal viel Zeit habe, wäre das das nächste Python-Projekt.

Tipp des Tages: Anzahl der Leerzeichen zu Beginn einer Zeichenkette zählen

Ob eine Zeichenkette mit einem oder mehreren Leerzeichen beginnt, lässt sich über die startswith()-Function des str-Objekts, dem die Zeichenkette zugrundeliegt, sehr einfach feststellen.

Möchte man aber die genaue Zahl der Leerzeichen, wird es etwas aufwändiger. Wer wie ein klassischer Entwickler denkt, versucht es vielleicht mit einer Schleife, die alle Zeichen bis zum nächsten Zeichen durchgeht, das kein Leerzeichen ist. Mit Hilfe eines regulären Ausdrucks (auch Regex genannt), geht es ein wenig einfacher.

Voraussetzung ist, dass das Modul re importiert wurde. Dann gibt es u.a. die findall()-Function, die mit zwei Werten aufgerufen wird:

>Einem regulären Ausdruck

>Dem Text, in dem die „Muster“ gefunden werden sollen

Der Text ist die Zeichenkette, die mit Leerzeichen beginnt. Das war einfach. Beim Muster wird es etwas „komplizierter“, denn man muss sich natürlich ein wenig mit der Schreibweise regulärer Ausdrücke auskennen.

Die Erklärung in absoluter Kurzform:

\s steht für ein Leerzeichen

der folgende * steht für 0, 1 oder beliebig viele Zeichen, in diesem Fall also Leerzeichen

durch die runden Klammer wird eine Gruppe gebildet

der Punkt steht für ein beliebiges Zeichen

der folgende * steht wieder für 0, 1 oder beliebig viele Zeichen, in diesem Fall kann es ein beliebiges Zeichen sein

Der Aufruf von findall() gibt eine Liste aller „Treffer“zurück. Und da der Treffer aus zwei Gruppen besteht, resultiert eine Liste mit Listen. Da es um die erste Gruppe im ersten Treffer geht, sieht der Aufruf wie folgt aus:

t = "    abc"
len(re.findall("(\s*)(.*)",t)[0][0])

Reguläre Ausdrücke wirken auf den ersten Blick unglaublich kryptisch und kompliziert. Sie sind es aber nicht bzw. mit einfachen Grundregeln kommt ihr bereits sehr weit. Die wichtigste Merkregel für den Anfang ist immer: Der * ist kein Platzhalter (sondern eine Mengenangabe) und der unscheinbare Punkt steht für ein beliebiges Zeichen.

Tipp des Tages: Eine Zeichenkette umdrehen

Der folgende Tipp fällt eher in die Kategorie „Spielerei“ bzw. „Was mit Slicing alles möglich ist“ (die Idee stammt auch nicht von mir, sondern von einem Teilnehmer meines letzten Python-Kurses).

Folgende Aufgabenstellung: Ausgabe einer Zeichenkette in umgedrehter Reihenfolge. Aus „Python ist ganz nett“ soll z.B. „tten znag tsi nohtyP“ werden.

Wer bei Python denkt wie ein Entwickler, versucht es vielleicht mit einer for-Wiederholung, die am Ende der Zeichenkette beginnt und Zeichen für eine Zeichen eine neue Zeichenkette zusammensetzt:

s1 = "Python ist ganz nett"
s2 = ""
for i in range(len(s1)-1,-1 ,-1):
    s2 += s1[i]

Das ist ok, aber für Python doch etwas umständlich. Sehr viel einfacher geht es dank der universellen Slicing-Schreibweise, die sich auf jede Liste anwenden lässt. Und da bei Python ein String ebenfalls eine Liste (von Zeichen) ist, funktioniert Slicing auch bei Strings (und stellt den Ersatz für eventuell vertraute Functions wie Left, Right oder Substr dar, die es bei Python nicht gibt).

Python ist ganz nett"[::-1]

Ich muss zugeben, dass ich darauf von alleine nicht unbedingt gekommen wäre, aber wenn man es so sieht, ist es ja eigentlich ganz logisch;)