Calculating `ConduitFitting` length

Performing system analysis on conduits in Revit can be a drag, apparently; it’s not possible to create a combined schedule of conduits and fittings with accurate total run lengths. pyRevit to the rescue!

I’m making some MEP tools to help address this and got pretty far this weekend inspecting conduit elements and even pulling up the conduit run data associated with them. I can see the total run length, which seems accurate, but when breaking down the individual element lengths I ran into the issue of not having a simple way of getting fitting lengths.

I have a simple project that I’m testing against, with a conduit system involving two straight runs of 100 ft. and 12 ft. and a single 90 deg. bend, as shown:

My script reports the following for the conduit run:

<Autodesk.Revit.DB.Electrical.Conduit object at 0x000000000000AA1A [Autodesk.Revit.DB.Electrical.Conduit]>
Conduit run ID: 366794
Conduit run: <Autodesk.Revit.DB.Electrical.ConduitRun object at 0x000000000000AA1B [Autodesk.Revit.DB.Electrical.ConduitRun]>
Conduit run length: 63.3472527470

This indicates that the fitting length is included in the total, for a length of 3.347 ft. For that fitting, however, my script reports the estimated bend length (using bend_length = (pi * rad_inch * ang) / 180) as being:

Type: <Autodesk.Revit.DB.FamilyInstance object at 0x000000000000AA1D [Autodesk.Revit.DB.FamilyInstance]>
Id: 366721
Level: Level 1
Outside diameter:
RunId(s): [366794]
Angle (deg.): 90.0
Bend radius (ft): 1.52083333333
Bend len. (in.): 28.6670329640

From what I now understand the fittings can only be an estimate due to the way Revit treats such family instances, but I’m trying to understand the large difference between the calculated length above of 28.6670329640 in. and the 40.167032964 in. I should get as the balance of the total run.

I’ve read some older posts on Dynamo forums discussing this and based my approach on their recommendation, but it just doesn’t seem accurate enough to be confident with.

It’s been a while so I’m going mostly from memory. Here are the steps on how I accomplished it.

  1. I added an arc length annotation to the family. See image.
    *Edit: I had the wall centerlines option selected.
    Click 1 = The centerline of the elbow.
    Clicks 2 & 3 = The flat edge of the sweep that makes the elbow.
  2. Assigned that annotation to a reporting parameter.

    4.The parameter can then be looked up and summed as you traverse the conduit run with the Revit API.

Hope this helps anyone trying to accomplish finding the conduit run length!

*Edit
I should add that this will give you the center line distance arc length through the elbow. So the accuracy is on point. :ok_hand:

Thank you, @Giuliano, I will try this next. Does this mean that the fitting centerline cannot be reliably accessed by the API on its own?

I don’t know the answer to that. I edited my conduit elbow family to get the center line arc length long before I was dabbling with the Revit API. But I eventually wanted to achieve the same thing you are attempting to do in getting the conduit run length. And it simplified it greatly to just have a parameter with the accurate length to be able to sum with my conduit lengths.

PS
You might as well get the total run degrees while you’re at it. Since the NEC limits conduit to 360°. Plus other specs might have a lower limit.

All in all the tool I have gets the run length, total degrees, and then pushes that and all other parameter information to the rest of the connected elements in the conduit run. If there are any discrepancies, a gui pops up to let the user choose what value to keep or it lets them ignore the discrepancy and that parameter is ignored.

Any chance you can share your code? I have a coworker who’s asking for a similar tool.

I am going to second @Giuliano. I tried to create this fitting length script a while back and didn’t know how to get the curve. Adding an arc length parameter to the family and using the pyrevit sum total tool was the easiest workaround.

I vibe coded something with Copilot and validated it’s results on a few simple runs.

For elbows, it tries to use a few specific parameters to determine the fitting length, and if they are unavailable it approximates the elbow curve based on the positions of the fitting connectors. This approximation will never be perfect because Revit doesn’t inherently know the length of the straight segments of the fitting unless you pass them to a parameter that the code can access.

I didn’t need it to calculate j-boxes at this time so it assumes they add zero length for now.

Not my cleanest code, but hopefully it helps others (or me in the future lol).

__title__ = "Conduit Run Length"

import clr
import math

clr.AddReference("RevitAPI")
clr.AddReference("RevitAPIUI")

from Autodesk.Revit.DB import (
    BuiltInCategory, MEPCurve, FamilyInstance, UnitFormatUtils, SpecTypeId, XYZ
)
from Autodesk.Revit.UI.Selection import ObjectType, ISelectionFilter

from pyrevit import revit, forms, script

doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()


# -------------------------------
# Selection Filter
# -------------------------------
class ConduitAndFittingFilter(ISelectionFilter):
    def AllowElement(self, elem):
        try:
            if elem.Category is None:
                return False
            bic = elem.Category.Id.IntegerValue
            return bic in (
                int(BuiltInCategory.OST_Conduit),
                int(BuiltInCategory.OST_ConduitFitting),
            )
        except:
            return False

    def AllowReference(self, ref, point):
        return False


# -------------------------------
# Connector helpers
# ConnectorManager access differs for MEPCurve vs FamilyInstance [1](https://pythoncvc.net/?p=417)
# -------------------------------
def get_connector_manager(elem):
    try:
        return elem.ConnectorManager  # MEPCurve (Conduit)
    except:
        pass
    try:
        return elem.MEPModel.ConnectorManager  # FamilyInstance (fittings)
    except:
        return None


def get_connectors(elem):
    cm = get_connector_manager(elem)
    if not cm:
        return []
    try:
        return list(cm.Connectors)
    except:
        return []


def is_conduit_or_fitting(elem):
    if elem is None or elem.Category is None:
        return False
    bic = elem.Category.Id.IntegerValue
    return bic in (
        int(BuiltInCategory.OST_Conduit),
        int(BuiltInCategory.OST_ConduitFitting),
    )


# -------------------------------
# Graph traversal: get connected component (run/network)
# -------------------------------
def get_connected_component(seed_elem, global_seen):
    comp = []
    queue = [seed_elem]
    local_seen = set()

    while queue:
        e = queue.pop()
        if e is None:
            continue
        eid = e.Id.IntegerValue
        if eid in global_seen or eid in local_seen:
            continue
        if not is_conduit_or_fitting(e):
            continue

        local_seen.add(eid)
        comp.append(e)

        for c in get_connectors(e):
            try:
                for rc in c.AllRefs:
                    owner = rc.Owner
                    if owner is None:
                        continue
                    if owner.Id.IntegerValue == eid:
                        continue
                    if is_conduit_or_fitting(owner):
                        queue.append(owner)
            except:
                continue

    for eid in local_seen:
        global_seen.add(eid)

    return comp


# -------------------------------
# Length computation
# -------------------------------
def conduit_length_internal(conduit_elem):
    # LocationCurve length (internal feet)
    try:
        loc = conduit_elem.Location
        crv = loc.Curve
        return crv.Length
    except:
        # fallback: try parameter "Length"
        try:
            p = conduit_elem.LookupParameter("Length")
            if p:
                return p.AsDouble()
        except:
            pass
    return 0.0


def _normalize(v):
    try:
        ln = v.GetLength()
        if ln < 1e-9:
            return v
        return XYZ(v.X / ln, v.Y / ln, v.Z / ln)
    except:
        return v

import math
from Autodesk.Revit.DB import SpecTypeId, UnitUtils

def _get_param_as_double(elem, names):
    """Try multiple parameter names and return AsDouble() or None."""
    for n in names:
        p = elem.LookupParameter(n)
        if p and p.StorageType.ToString() == "Double":
            try:
                return p.AsDouble()
            except:
                pass
    return None

def _looks_like_degrees(angle_val):
    """
    Heuristic: if it's > ~2*pi, it's probably degrees (e.g. 90, 45, 30).
    API internal for angles is radians. [2](https://www.revitapidocs.com/2026/128dd879-fea8-5d7b-1eb2-d64f87753990.htm)
    """
    return angle_val is not None and angle_val > (2.0 * math.pi + 0.5)

def fitting_developed_length_from_params_internal(fit_elem, warnlist=None):
    """
    Developed length = 2*offset + radius*angle
    - radius: internal feet
    - offset: internal feet (per end)
    - angle: radians (if degrees detected, convert)
    """
    if warnlist is None:
        warnlist = []

    # Adjust these name lists to match your content standard
    radius = _get_param_as_double(fit_elem, [
        "Bend Radius", "Radius", "Centerline Radius", "Bend radius"
    ])

    angle = _get_param_as_double(fit_elem, [
        "Angle", "Bend Angle", "Conduit Angle"
    ])

    offset = _get_param_as_double(fit_elem, [
        "Conduit Length", "End Offset", "Takeoff", "Extension", "Stub Length"
    ])

    # If any are missing, bail out (caller can fallback)
    if radius is None or angle is None or offset is None:
        warnlist.append("Missing parameters on fitting id {} (radius={}, angle={}, offset={})"
                        .format(fit_elem.Id.IntegerValue,
                                radius is not None, angle is not None, offset is not None))
        return None

    # Convert degrees -> radians if needed (defensive)
    if _looks_like_degrees(angle):
        angle = angle * math.pi / 180.0

    # Developed length in internal feet
    total = (2.0 * offset) + (radius * angle)
    return total

def fitting_developed_length_internal(fit_elem, warnlist):
    # 1) Try family parameters first
    val = fitting_developed_length_from_params_internal(fit_elem, warnlist)
    if val is not None:
        return val

    # 2) Fallback: Connector-based estimate
    warnlist.append("Falling back to connector estimate for fitting id {}"
                    .format(fit_elem.Id.IntegerValue))
    return fitting_developed_length_from_connectors(fit_elem, warnlist)

def fitting_developed_length_from_connectors(fit_elem, warnlist):
    """
    For 2-connector fittings:
      - If connectors nearly colinear (theta ~ pi), treat as straight: length = chord
      - Else treat as bend: arc length computed from chord + included angle

    For 3+ connectors (tees/cross), return 0 and warn.
    """
    conns = get_connectors(fit_elem)
    # keep only connectors with an origin
    conns = [c for c in conns if getattr(c, "Origin", None) is not None]

    if len(conns) != 2:
        # tees/crosses/etc.
        warnlist.append("Skipped fitting id {} ({} connectors)".format(
            fit_elem.Id.IntegerValue, len(conns)
        ))
        return 0.0

    c1, c2 = conns[0], conns[1]
    p1, p2 = c1.Origin, c2.Origin
    chord = p1.DistanceTo(p2)

    # connector direction vectors
    try:
        d1 = _normalize(c1.CoordinateSystem.BasisZ)
        d2 = _normalize(c2.CoordinateSystem.BasisZ)
        dot = max(-1.0, min(1.0, d1.DotProduct(d2)))
        theta = math.acos(dot)  # radians (internal for angles) [3](https://forums.autodesk.com/t5/revit-api-forum/how-to-get-angle-parameter-from-familyinstance/td-p/9988944)
    except:
        # if direction can't be read, fallback to chord
        return chord

    # If nearly straight (connector dirs opposite => theta ~ pi), use chord
    if abs(math.pi - theta) < 1e-3 or theta < 1e-6:
        return chord

    # Arc length from chord and included angle
    s = math.sin(theta / 2.0)
    if abs(s) < 1e-9:
        return chord

    R = chord / (2.0 * s)
    arc = R * theta
    return arc


def format_length(val_internal):
    # Format in project units (internal feet -> formatted string)
    try:
        return UnitFormatUtils.Format(doc.GetUnits(), SpecTypeId.Length, val_internal, False)
    except:
        # fallback: raw feet
        return "{:.3f} ft".format(val_internal)


# # -------------------------------
# # Main
# # -------------------------------
sel_ids = list(uidoc.Selection.GetElementIds())
seed_elems = []

if sel_ids:
    seed_elems = [doc.GetElement(i) for i in sel_ids if doc.GetElement(i)]
else:
    try:
        picked = uidoc.Selection.PickObjects(
            ObjectType.Element,
            ConduitAndFittingFilter(),
            "Select conduit(s) and/or conduit fitting(s) to define run(s)"
        )
        seed_elems = [doc.GetElement(r.ElementId) for r in picked]
    except:
        forms.alert("Selection canceled.", exitscript=True)

# Build connected components starting from each seed
global_seen = set()
runs = []

for s in seed_elems:
    if not s:
        continue
    if s.Id.IntegerValue in global_seen:
        continue
    comp = get_connected_component(s, global_seen)
    if comp:
        runs.append(comp)

if not runs:
    forms.alert("No connected conduit/fitting elements found.", exitscript=True)

# Report
grand_total = 0.0

output.print_md("# Conduit Run Length Report")
output.print_md("Found **{}** connected run(s).".format(len(runs)))

for idx, elems in enumerate(runs, start=1):
    conduits = [e for e in elems if isinstance(e, MEPCurve)]
    fittings = [e for e in elems if isinstance(e, FamilyInstance)]

    warn = []
    conduit_sum = sum(conduit_length_internal(c) for c in conduits)
    fitting_sum = sum(fitting_developed_length_internal(f, warn) for f in fittings)

    total = conduit_sum + fitting_sum
    grand_total += total

    output.print_md("## Run {}".format(idx))
    output.print_md("- Elements: **{}** (Conduit: **{}**, Fittings: **{}**)".format(
        len(elems), len(conduits), len(fittings)
    ))
    output.print_md("- Conduit length: **{}**".format(format_length(conduit_sum)))
    output.print_md("- Fitting developed length (2-connector only): **{}**".format(format_length(fitting_sum)))
    output.print_md("- **TOTAL RUN LENGTH: {}**".format(format_length(total)))

    if warn:
        output.print_md("### Notes")
        for w in warn:
            output.print_md("- {}".format(w))

output.print_md("---")
output.print_md("Grand Total all runs")
output.print_md("**{}**".format(format_length(grand_total)))
print("Script complete.")