import matplotlib.pyplot as plt
from matplotlib.lines import Line2D


# ============================================================
# 1. PROGRAM VARIABLES
# ============================================================

ConnectionMaterial = 2.5   # meters added at both ends of each connection
TrackDistance = 6.0        # meters between adjacent tracks
RiskFactor = 0.20          # 20% extra fiber

KPtoMeters = 1000          # KP values are in kilometers. 1 KP = 1000 meters

# Length group limits in meters
Limit1 = 50
Limit2 = 100
Limit3 = 200
Limit4 = 500


# ============================================================
# 2. INPUT POINTS
# ============================================================
# The route follows the same order used in this list.
# Point 1 connects to Point 2.
# Point 2 connects to Point 3.
# Point 3 connects to Point 4, etc.

points = [
    {"Name": "241/3", "Track": 1, "KP": 37.8},
    {"Name": "242/3", "Track": 2, "KP": 37.8},
    {"Name": "243/3", "Track": 3, "KP": 37.8},
    {"Name": "241/4", "Track": 3, "KP": 37.9},
]


# ============================================================
# 3. FUNCTIONS
# ============================================================

def validate_points(points):
    """
    Validates input points before creating connections.

    Rules:
    - Each point must have Name, Track, and KP.
    - At least two route entries are required.
    - Repeated names are allowed.
    - Same name with different Track or KP is allowed.
    """

    if len(points) < 2:
        raise ValueError("At least two points are required to create one connection.")

    required_keys = ["Name", "Track", "KP"]

    for index, point in enumerate(points):
        for key in required_keys:
            if key not in point:
                raise ValueError(
                    f"Point at position {index + 1} is missing key: {key}"
                )

    print("Point validation completed successfully.")


def calculate_connection(
    p1,
    p2,
    connection_material,
    track_distance,
    kp_to_meters
):
    """
    Calculates base fiber length between two consecutive points.

    Rules:
    - Same Track:
        Fiber Length = KP difference converted to meters
                       + 2 * ConnectionMaterial

    - Different Track:
        Fiber Length = number of crossed track spaces * TrackDistance
                       + 2 * ConnectionMaterial

    Note:
    RiskFactor is NOT applied here.
    RiskFactor is applied later in count_fiber_by_length_group().
    """

    same_track = p1["Track"] == p2["Track"]
    same_kp = p1["KP"] == p2["KP"]

    if same_track:
        kp_distance_m = abs(p2["KP"] - p1["KP"]) * kp_to_meters

        fiber_length = kp_distance_m + (2 * connection_material)
        connection_type = "Same Track"
        track_steps = 0
        warning = ""

    else:
        track_steps = abs(p2["Track"] - p1["Track"])

        fiber_length = (track_steps * track_distance) + (2 * connection_material)
        connection_type = "Track Change"

        if same_kp:
            warning = ""
        else:
            warning = "WARNING: Track change with different KP. Diagonal connection detected."

    return fiber_length, connection_type, track_steps, warning


def create_connections(
    points,
    connection_material,
    track_distance,
    kp_to_meters
):
    """
    Creates all connections based on the order of the points list.

    Points are identified internally by index.
    This allows repeated point names.

    Also detects return connections.
    """

    connections = []

    previous_signatures = set()

    previous_signatures.add(
        (
            points[0]["Name"],
            points[0]["Track"],
            points[0]["KP"]
        )
    )

    for i in range(len(points) - 1):
        p1 = points[i]
        p2 = points[i + 1]

        fiber_length, connection_type, track_steps, warning = calculate_connection(
            p1,
            p2,
            connection_material,
            track_distance,
            kp_to_meters
        )

        destination_signature = (
            p2["Name"],
            p2["Track"],
            p2["KP"]
        )

        is_return = destination_signature in previous_signatures

        connection = {
            "From_Index": i,
            "To_Index": i + 1,

            "From": p1["Name"],
            "From_Track": p1["Track"],
            "From_KP": p1["KP"],

            "To": p2["Name"],
            "To_Track": p2["Track"],
            "To_KP": p2["KP"],

            "Connection_Type": connection_type,
            "Track_Steps": track_steps,
            "Fiber_Length_m": fiber_length,
            "Is_Return": is_return,
            "Warning": warning
        }

        connections.append(connection)

        previous_signatures.add(destination_signature)

    return connections


def count_fiber_by_length_group(
    connections,
    risk_factor,
    limit1,
    limit2,
    limit3,
    limit4
):
    """
    Applies RiskFactor to every connection and groups connections by length.

    Returns:
    - updated_connections
    - grouped_summary
    - total_base_fiber
    - total_fiber_with_risk
    """

    grouped_summary = {
        f"0 to {limit1} m": {
            "Connections_Count": 0,
            "Total_Fiber_m": 0
        },
        f"{limit1} to {limit2} m": {
            "Connections_Count": 0,
            "Total_Fiber_m": 0
        },
        f"{limit2} to {limit3} m": {
            "Connections_Count": 0,
            "Total_Fiber_m": 0
        },
        f"{limit3} to {limit4} m": {
            "Connections_Count": 0,
            "Total_Fiber_m": 0
        },
        f"Above {limit4} m": {
            "Connections_Count": 0,
            "Total_Fiber_m": 0
        }
    }

    updated_connections = []
    total_base_fiber = 0
    total_fiber_with_risk = 0

    for connection in connections:
        base_length = connection["Fiber_Length_m"]

        fiber_length_with_risk = base_length * (1 + risk_factor)

        if 0 <= fiber_length_with_risk <= limit1:
            length_group = f"0 to {limit1} m"

        elif limit1 < fiber_length_with_risk <= limit2:
            length_group = f"{limit1} to {limit2} m"

        elif limit2 < fiber_length_with_risk <= limit3:
            length_group = f"{limit2} to {limit3} m"

        elif limit3 < fiber_length_with_risk <= limit4:
            length_group = f"{limit3} to {limit4} m"

        else:
            length_group = f"Above {limit4} m"

        connection_copy = connection.copy()
        connection_copy["RiskFactor"] = risk_factor
        connection_copy["Fiber_Length_With_Risk_m"] = fiber_length_with_risk
        connection_copy["Length_Group"] = length_group

        updated_connections.append(connection_copy)

        grouped_summary[length_group]["Connections_Count"] += 1
        grouped_summary[length_group]["Total_Fiber_m"] += fiber_length_with_risk

        total_base_fiber += base_length
        total_fiber_with_risk += fiber_length_with_risk

    return updated_connections, grouped_summary, total_base_fiber, total_fiber_with_risk


def plot_route_representation(
    points,
    updated_connections,
    output_file="fiber_route_representation.png"
):
    """
    Creates a visual route representation.

    Visual rules:
    - Points with the same KP are aligned vertically.
    - Repeated point names are allowed.
    - KP is NOT shown inside the circle.
    - Only Name is shown inside the circle.
    - Circle is 60% larger.
    - Name text is 60% larger.
    - Horizontal KP spacing = 3.2 diameters of the new circle.
      This is 20% closer than the previous 4 diameters.
    - Same Track: strong green / thick line.
    - Track Change: thin green / thinner line.
    - Return Connection: blue / thicker line and slightly offset.
    - Step labels removed.
    """

    import matplotlib.pyplot as plt
    from matplotlib.patches import Circle
    from matplotlib.lines import Line2D

    tracks = sorted({p["Track"] for p in points})

    y_map = {
        track: -idx
        for idx, track in enumerate(tracks)
    }

    # ------------------------------------------------------------
    # Visual sizing variables
    # ------------------------------------------------------------
    CircleScale = 1.60

    BaseCircleRadius = 0.16
    CircleRadius = BaseCircleRadius * CircleScale
    CircleDiameter = 2 * CircleRadius

    BaseNameFontSize = 8
    NameFontSize = BaseNameFontSize * CircleScale

    # Previous value was 4.
    # New value is 20% closer:
    # 4 * 0.80 = 3.2
    HorizontalSpacingInDiameters = 3.2
    KPSpacing = HorizontalSpacingInDiameters * CircleDiameter

    KPFontSize = 8
    LabelFontSize = 8

    # ------------------------------------------------------------
    # Keep KP order based on first appearance
    # ------------------------------------------------------------
    unique_kps = []

    for p in points:
        if p["KP"] not in unique_kps:
            unique_kps.append(p["KP"])

    kp_x_map = {
        kp: idx * KPSpacing
        for idx, kp in enumerate(unique_kps)
    }

    x_positions = [
        kp_x_map[p["KP"]]
        for p in points
    ]

    strong_green = "#00A651"
    thin_green = "#7CFC00"
    return_blue = "#0070C0"

    fig, ax = plt.subplots(figsize=(14, 5))

    min_x = min(kp_x_map.values())
    max_x = max(kp_x_map.values())

    # ------------------------------------------------------------
    # Draw track guide lines
    # ------------------------------------------------------------
    for track in tracks:
        y = y_map[track]

        ax.hlines(
            y,
            min_x - (2 * CircleDiameter),
            max_x + (6 * CircleDiameter),
            color="#D0D0D0",
            linewidth=1,
            linestyles="--"
        )

        ax.text(
            min_x - (2.5 * CircleDiameter),
            y,
            f"Track {track}",
            va="center",
            ha="right",
            fontsize=10,
            fontweight="bold"
        )

    # ------------------------------------------------------------
    # Draw connections
    # ------------------------------------------------------------
    for connection_number, c in enumerate(updated_connections, start=1):

        from_index = c["From_Index"]
        to_index = c["To_Index"]

        from_point = points[from_index]
        to_point = points[to_index]

        x1 = x_positions[from_index]
        y1 = y_map[from_point["Track"]]

        x2 = x_positions[to_index]
        y2 = y_map[to_point["Track"]]

        if c.get("Is_Return", False):
            color = return_blue
            linewidth = 5.5
            zorder_line = 4

        elif c["Connection_Type"] == "Same Track":
            color = strong_green
            linewidth = 5
            zorder_line = 2

        else:
            color = thin_green
            linewidth = 2.5
            zorder_line = 3

        # --------------------------------------------------------
        # Same Track connection
        # --------------------------------------------------------
        if c["Connection_Type"] == "Same Track":

            ax.plot(
                [x1, x2],
                [y1, y2],
                color=color,
                linewidth=linewidth,
                zorder=zorder_line
            )

            label_x = (x1 + x2) / 2
            label_y = y1 + (1.25 * CircleDiameter)

        # --------------------------------------------------------
        # Track Change connection
        # --------------------------------------------------------
        else:

            # Same KP / different track
            if x1 == x2:

                offset_step = 0.55 * CircleDiameter

                if c.get("Is_Return", False):
                    x_offset = x1 + offset_step
                    label_x = x_offset + (0.65 * CircleDiameter)
                else:
                    x_offset = x1
                    label_x = x_offset + (0.65 * CircleDiameter)

                ax.plot(
                    [x_offset, x_offset],
                    [y1, y2],
                    color=color,
                    linewidth=linewidth,
                    zorder=zorder_line
                )

                ax.scatter(
                    [x_offset, x_offset],
                    [y1, y2],
                    s=45,
                    color=color,
                    zorder=zorder_line + 1
                )

                label_y = (y1 + y2) / 2

            # Different KP / different track
            else:
                mid_x = (x1 + x2) / 2

                ax.plot(
                    [x1, mid_x],
                    [y1, y1],
                    color=color,
                    linewidth=linewidth,
                    zorder=zorder_line
                )

                ax.plot(
                    [mid_x, mid_x],
                    [y1, y2],
                    color=color,
                    linewidth=linewidth,
                    zorder=zorder_line
                )

                ax.plot(
                    [mid_x, x2],
                    [y2, y2],
                    color=color,
                    linewidth=linewidth,
                    zorder=zorder_line
                )

                label_x = mid_x
                label_y = (y1 + y2) / 2

        # --------------------------------------------------------
        # Connection label
        # --------------------------------------------------------
        if c.get("Is_Return", False):
            label_title = f"#{connection_number} RETURN"
        else:
            label_title = f"#{connection_number}"

        ax.text(
            label_x,
            label_y,
            f'{label_title}\n{c["Fiber_Length_With_Risk_m"]:.1f} m\n{c["Length_Group"]}',
            ha="center",
            va="center",
            fontsize=LabelFontSize,
            bbox={
                "facecolor": "white",
                "edgecolor": "#BBBBBB",
                "boxstyle": "round,pad=0.25",
                "alpha": 0.95
            },
            zorder=6
        )

    # ------------------------------------------------------------
    # Draw points / circles
    # ------------------------------------------------------------
    for idx, p in enumerate(points):
        x = x_positions[idx]
        y = y_map[p["Track"]]

        circle = Circle(
            (x, y),
            radius=CircleRadius,
            facecolor="white",
            edgecolor="black",
            linewidth=1.5,
            zorder=7
        )

        ax.add_patch(circle)

        # Only NAME inside circle
        ax.text(
            x,
            y,
            f'{p["Name"]}',
            ha="center",
            va="center",
            fontsize=NameFontSize,
            fontweight="bold",
            zorder=8
        )

    # ------------------------------------------------------------
    # KP labels at bottom
    # ------------------------------------------------------------
    bottom_y = min(y_map.values()) - (1.5 * CircleDiameter)

    for kp, x in kp_x_map.items():
        ax.text(
            x,
            bottom_y,
            f"KP {kp}",
            ha="center",
            va="top",
            fontsize=KPFontSize,
            color="#555555"
        )

    # ------------------------------------------------------------
    # Legend
    # ------------------------------------------------------------
    legend_elements = [
        Line2D(
            [0],
            [0],
            color=strong_green,
            lw=5,
            label="Same Track - Verde fuerte"
        ),
        Line2D(
            [0],
            [0],
            color=thin_green,
            lw=2.5,
            label="Track Change - Verde delgado"
        ),
        Line2D(
            [0],
            [0],
            color=return_blue,
            lw=5.5,
            label="Return Connection - Azul"
        ),
    ]

    ax.legend(
        handles=legend_elements,
        loc="upper right"
    )

    ax.set_title("Fiber Optic Route Representation - KP Aligned")

    extra_space = max(1, len(updated_connections) * CircleDiameter)

    ax.set_xlim(
        min_x - (3 * CircleDiameter),
        max_x + (6 * CircleDiameter) + extra_space
    )

    ax.set_ylim(
        min(y_map.values()) - (2.5 * CircleDiameter),
        max(y_map.values()) + (3.0 * CircleDiameter)
    )

    ax.set_yticks([])
    ax.set_xticks([])
    ax.set_frame_on(False)

    plt.tight_layout()
    plt.savefig(output_file, dpi=200, bbox_inches="tight")
    print(f"Route image generated: {output_file}")
    plt.show()

def calculate_route_metrics(points, kp_to_meters):
    """
    Calculates general route metrics.

    Metrics:
    - Total number of connection points / circles.
    - Linear distance between the most separated KPs in meters.
    """

    total_connection_points = len(points)

    min_kp = min(p["KP"] for p in points)
    max_kp = max(p["KP"] for p in points)

    linear_distance = (max_kp - min_kp) * kp_to_meters

    min_kp_points = [
        p for p in points
        if p["KP"] == min_kp
    ]

    max_kp_points = [
        p for p in points
        if p["KP"] == max_kp
    ]

    route_metrics = {
        "Total_Connection_Points": total_connection_points,
        "Min_KP": min_kp,
        "Max_KP": max_kp,
        "Linear_Distance_m": linear_distance,
        "Min_KP_Points": min_kp_points,
        "Max_KP_Points": max_kp_points
    }

    return route_metrics


def print_report(updated_connections, grouped_summary, total_base_fiber, total_fiber_with_risk):
    """
    Prints fiber optic connection report.
    """

    print("Fiber Optic Conservative Length Report")
    print("-" * 135)

    print(
        f'{"From":<12}'
        f'{"From KP":<12}'
        f'{"To":<12}'
        f'{"To KP":<12}'
        f'{"Track Steps":<15}'
        f'{"Return":<10}'
        f'{"Base Length":<18}'
        f'{"Conservative Length":<25}'
        f'{"Distance Group":<20}'
        f'{"Type":<15}'
    )

    print("-" * 135)

    for c in updated_connections:
        print(
            f'{c["From"]:<12}'
            f'{c["From_KP"]:<12}'
            f'{c["To"]:<12}'
            f'{c["To_KP"]:<12}'
            f'{c["Track_Steps"]:<15}'
            f'{str(c["Is_Return"]):<10}'
            f'{c["Fiber_Length_m"]:<18.2f}'
            f'{c["Fiber_Length_With_Risk_m"]:<25.2f}'
            f'{c["Length_Group"]:<20}'
            f'{c["Connection_Type"]:<15}'
        )

        if c["Warning"]:
            print(f'WARNING: {c["Warning"]}')

    print("-" * 135)

    print("\nFiber Length Group Summary")
    print("-" * 100)

    print(
        f'{"Distance Group":<25}'
        f'{"Connections":<15}'
        f'{"Total Conservative Length":<30}'
    )

    print("-" * 100)

    for group_name, values in grouped_summary.items():
        print(
            f'{group_name:<25}'
            f'{values["Connections_Count"]:<15}'
            f'{values["Total_Fiber_m"]:<30.2f}'
        )

    print("-" * 100)
    print(f"Total Base Fiber Length = {total_base_fiber:.2f} m")
    print(f"Total Conservative Fiber Length = {total_fiber_with_risk:.2f} m")


def print_route_metrics(route_metrics):
    """
    Prints general route metrics.
    """

    print("\nRoute General Metrics")
    print("-" * 100)

    print(
        f'Total connection points / circles = '
        f'{route_metrics["Total_Connection_Points"]}'
    )

    print(
        f'Linear distance between most separated points = '
        f'{route_metrics["Linear_Distance_m"]:.2f} m'
    )

    print(f'Min KP = {route_metrics["Min_KP"]}')

    print("Point(s) at Min KP:")
    for p in route_metrics["Min_KP_Points"]:
        print(
            f'  {p["Name"]} | Track {p["Track"]} | KP {p["KP"]}'
        )

    print(f'Max KP = {route_metrics["Max_KP"]}')

    print("Point(s) at Max KP:")
    for p in route_metrics["Max_KP_Points"]:
        print(
            f'  {p["Name"]} | Track {p["Track"]} | KP {p["KP"]}'
        )

    print("-" * 100)

def save_txt_report(
    updated_connections,
    grouped_summary,
    total_base_fiber,
    total_fiber_with_risk,
    route_metrics,
    output_file="fiber_route_report.txt"
):
    """
    Saves the same report information printed in console into a TXT file.
    """

    with open(output_file, "w", encoding="utf-8") as file:

        file.write("Fiber Optic Conservative Length Report\n")
        file.write("-" * 135 + "\n")

        file.write(
            f'{"From":<12}'
            f'{"From KP":<12}'
            f'{"To":<12}'
            f'{"To KP":<12}'
            f'{"Track Steps":<15}'
            f'{"Return":<10}'
            f'{"Base Length":<18}'
            f'{"Conservative Length":<25}'
            f'{"Distance Group":<20}'
            f'{"Type":<15}'
            + "\n"
        )

        file.write("-" * 135 + "\n")

        for c in updated_connections:
            file.write(
                f'{c["From"]:<12}'
                f'{c["From_KP"]:<12}'
                f'{c["To"]:<12}'
                f'{c["To_KP"]:<12}'
                f'{c["Track_Steps"]:<15}'
                f'{str(c["Is_Return"]):<10}'
                f'{c["Fiber_Length_m"]:<18.2f}'
                f'{c["Fiber_Length_With_Risk_m"]:<25.2f}'
                f'{c["Length_Group"]:<20}'
                f'{c["Connection_Type"]:<15}'
                + "\n"
            )

            if c["Warning"]:
                file.write(f'WARNING: {c["Warning"]}\n')

        file.write("-" * 135 + "\n\n")

        file.write("Fiber Length Group Summary\n")
        file.write("-" * 100 + "\n")

        file.write(
            f'{"Distance Group":<25}'
            f'{"Connections":<15}'
            f'{"Total Conservative Length":<30}'
            + "\n"
        )

        file.write("-" * 100 + "\n")

        for group_name, values in grouped_summary.items():
            file.write(
                f'{group_name:<25}'
                f'{values["Connections_Count"]:<15}'
                f'{values["Total_Fiber_m"]:<30.2f}'
                + "\n"
            )

        file.write("-" * 100 + "\n")
        file.write(f"Total Base Fiber Length = {total_base_fiber:.2f} m\n")
        file.write(f"Total Conservative Fiber Length = {total_fiber_with_risk:.2f} m\n")

        file.write("\n\n")
        file.write("Route General Metrics\n")
        file.write("-" * 100 + "\n")

        file.write(
            f'Total connection points / circles = '
            f'{route_metrics["Total_Connection_Points"]}\n'
        )

        file.write(
            f'Linear distance between most separated points = '
            f'{route_metrics["Linear_Distance_m"]:.2f} m\n'
        )

        file.write(f'Min KP = {route_metrics["Min_KP"]}\n')

        file.write("Point(s) at Min KP:\n")
        for p in route_metrics["Min_KP_Points"]:
            file.write(
                f'  {p["Name"]} | Track {p["Track"]} | KP {p["KP"]}\n'
            )

        file.write(f'Max KP = {route_metrics["Max_KP"]}\n')

        file.write("Point(s) at Max KP:\n")
        for p in route_metrics["Max_KP_Points"]:
            file.write(
                f'  {p["Name"]} | Track {p["Track"]} | KP {p["KP"]}\n'
            )

        file.write("-" * 100 + "\n")

    print(f"TXT report generated: {output_file}")

# ============================================================
# 4. MAIN CODE
# ============================================================

validate_points(points)

connections = create_connections(
    points,
    ConnectionMaterial,
    TrackDistance,
    KPtoMeters
)

updated_connections, grouped_summary, total_base_fiber, total_fiber_with_risk = count_fiber_by_length_group(
    connections,
    RiskFactor,
    Limit1,
    Limit2,
    Limit3,
    Limit4
)

route_metrics = calculate_route_metrics(
    points,
    KPtoMeters
)

plot_route_representation(
    points,
    updated_connections,
    output_file="fiber_route_representation.png"
)

print_report(
    updated_connections,
    grouped_summary,
    total_base_fiber,
    total_fiber_with_risk
)

print_route_metrics(route_metrics)


save_txt_report(
    updated_connections,
    grouped_summary,
    total_base_fiber,
    total_fiber_with_risk,
    route_metrics,
    output_file="fiber_route_report.txt"
)

Embed on website

To embed this project on your website, copy the following code and paste it into your website's HTML: