Snow Load Wizard#

../../../../_images/load_wizard_snow.png

Generating duopitch roof snow loads from GeoZone site data with the Snow Load Wizard:

  • Build a multi-frame timber portal structure with a symmetric duopitch roof

  • Query the GeoZone Tool for the site’s characteristic snow load and altitude

  • Activate the load-wizards add-on and assign the EN 1991 snow standard in base data

  • Define a duopitch Snow Load Wizard from the roof’s corner nodes

  • Write the GeoZone snow load and altitude into the wizard’s standard-parameters tree

  • Generate the balanced and asymmetric snow arrangements into separate load cases

Keywords:
snow load wizard duopitch roof GeoZone Tool
"""Parametric duopitch (gable) roof building with a Snow Load Wizard.

Builds a multi-frame timber skeleton with a symmetric duopitch roof, then adds a
Snow Load Wizard on the two roof slopes (RFEM "Gable/Duopitch"). Each portal
frame has two columns and two rafters (eaves to ridge); longitudinal beams tie
the frames together. The model is 1D members only - the snow wizard identifies
the two roof planes from the corner nodes and loads the rafters directly.

The default parameters reproduce the reference structure:

    Building length, L   = 12.0 m   (ridge direction)
    Building width,  B   = 12.0 m   (span across the slopes)
    Eaves height         =  5.0 m
    Ridge height, H      =  7.5 m   (ground to top of roof)
    Roof pitch, theta    = atan((7.5 - 5.0) / (12.0 / 2)) = 22.62 deg

Snow Load Wizard
----------------
Base Data activates the load-wizards add-on and assigns EN 1991 (DIN). The
characteristic snow load s_k and the site altitude are fetched from the GeoZone
Tool for GEOZONE_ADDRESS and written into the wizard's standard-parameters tree
(the GUI "Parameters" tab), mirroring response_spectrum_from_geozone.py for the
seismic a_gR value.

The duopitch roof corner nodes must be given in the order RFEM expects - a walk
around the perimeter of the folded roof (see the wizard's A-F diagram):

    A front-left eaves -> B rear-left eaves -> C rear ridge ->
    D rear-right eaves -> E front-right eaves -> F front ridge

EN 1991 produces three duopitch snow arrangements (Case i balanced, Cases ii/iii
asymmetric), so the wizard must generate into three DISTINCT load cases - one per
arrangement. Reusing a load case is rejected ("Load cases for generated loads
must differ.").

NOTE (bindings):
    Requires dlubal.api bindings that include load_wizards.SnowLoad. With the
    correct node order the TYPE_DUOPITCH wizard registers both roof planes
    (loaded_planes: 2 planes at 22.62 deg).

Units are SI: metres for geometry, Newtons for forces. RFEM uses a Z-down
convention, so structural heights map to negative Z coordinates.
"""

from dlubal.api import rfem, common, geo_zone_tool
from math import inf, radians

# -------------------------------------------------------------------------
# Editable parameters (SI units)
# -------------------------------------------------------------------------
BUILDING_LENGTH = 12.0            # m, ridge direction (global Y)
BUILDING_WIDTH = 12.0             # m, span across the slopes (global X)
EAVES_HEIGHT = 5.0                # m, ground to eaves
RIDGE_HEIGHT = 7.5                # m, ground to ridge (H)
NUMBER_OF_FRAMES = 4              # portal frames along the building length

MATERIAL = "C24"                  # timber, from the RFEM material library
CROSS_SECTION_COLUMN = "R_M1 0.120/0.240"   # 120 x 240 mm
CROSS_SECTION_RAFTER = "R_M1 0.100/0.200"   # 100 x 200 mm

# GeoZone Tool: the characteristic snow load s_k for this site is fetched from the
# GeoZone service and written into the wizard's standard-parameters tree (same
# pattern as response_spectrum_from_geozone.py, which transfers a_gR for seismic).
GEOZONE_ADDRESS = "München, Germany"
GEOZONE_COUNTRY_CODE = "DE"
SNOW_STANDARD_NAME = "EN 1991-1-3"   # preferred snow standard from the GeoZone list

def define_structure() -> list:
    """Define and return the structural objects of the duopitch building."""

    half_width = BUILDING_WIDTH / 2.0
    frame_spacing = BUILDING_LENGTH / (NUMBER_OF_FRAMES - 1)
    nodes_per_frame = 5       # left/right base, left/right eaves, ridge
    lines_per_frame = 4       # 2 columns + 2 rafters
    lines_per_bay = 3         # left eaves beam, right eaves beam, ridge beam
    first_bay_line = NUMBER_OF_FRAMES * lines_per_frame

    objects = [
        rfem.structure_core.Material(no=1, name=MATERIAL),
        rfem.structure_core.CrossSection(no=1, name=CROSS_SECTION_COLUMN, material=1),
        rfem.structure_core.CrossSection(no=2, name=CROSS_SECTION_RAFTER, material=1),
    ]

    # --- Nodes: 5 per frame (Z is negative for structural height) ---
    for f in range(NUMBER_OF_FRAMES):
        base = f * nodes_per_frame
        y = f * frame_spacing
        objects += [
            rfem.structure_core.Node(no=base + 1, coordinate_1=-half_width, coordinate_2=y, coordinate_3=0.0),
            rfem.structure_core.Node(no=base + 2, coordinate_1=half_width, coordinate_2=y, coordinate_3=0.0),
            rfem.structure_core.Node(no=base + 3, coordinate_1=-half_width, coordinate_2=y, coordinate_3=-EAVES_HEIGHT),
            rfem.structure_core.Node(no=base + 4, coordinate_1=half_width, coordinate_2=y, coordinate_3=-EAVES_HEIGHT),
            rfem.structure_core.Node(no=base + 5, coordinate_1=0.0, coordinate_2=y, coordinate_3=-RIDGE_HEIGHT),
        ]

    # --- Lines: 2 columns + 2 rafters per frame ---
    for f in range(NUMBER_OF_FRAMES):
        node = f * nodes_per_frame
        line = f * lines_per_frame
        objects += [
            rfem.structure_core.Line(no=line + 1, definition_nodes=[node + 1, node + 3]),  # left column
            rfem.structure_core.Line(no=line + 2, definition_nodes=[node + 2, node + 4]),  # right column
            rfem.structure_core.Line(no=line + 3, definition_nodes=[node + 3, node + 5]),  # left rafter
            rfem.structure_core.Line(no=line + 4, definition_nodes=[node + 4, node + 5]),  # right rafter
        ]

    # --- Lines: longitudinal beams tying consecutive frames (per bay) ---
    for bay in range(NUMBER_OF_FRAMES - 1):
        node = bay * nodes_per_frame
        line = first_bay_line + bay * lines_per_bay
        objects += [
            rfem.structure_core.Line(no=line + 1, definition_nodes=[node + 3, node + nodes_per_frame + 3]),  # left eaves
            rfem.structure_core.Line(no=line + 2, definition_nodes=[node + 4, node + nodes_per_frame + 4]),  # right eaves
            rfem.structure_core.Line(no=line + 3, definition_nodes=[node + 5, node + nodes_per_frame + 5]),  # ridge
        ]

    # --- Members: columns on CS 1, rafters and longitudinal beams on CS 2 ---
    for f in range(NUMBER_OF_FRAMES):
        line = f * lines_per_frame
        objects += [
            rfem.structure_core.Member(no=line + 1, line=line + 1, cross_section_start=1),  # left column
            rfem.structure_core.Member(no=line + 2, line=line + 2, cross_section_start=1),  # right column
            rfem.structure_core.Member(no=line + 3, line=line + 3, cross_section_start=2),  # left rafter
            rfem.structure_core.Member(no=line + 4, line=line + 4, cross_section_start=2),  # right rafter
        ]
    for bay in range(NUMBER_OF_FRAMES - 1):
        line = first_bay_line + bay * lines_per_bay
        for k in range(1, lines_per_bay + 1):
            objects.append(
                rfem.structure_core.Member(no=line + k, line=line + k, cross_section_start=2)
            )

    # --- Nodal supports: fixed column bases ---
    for f in range(NUMBER_OF_FRAMES):
        base = f * nodes_per_frame
        objects.append(
            rfem.types_for_nodes.NodalSupport(
                no=f + 1,
                nodes=[base + 1, base + 2],
                spring=common.Vector3d(x=inf, y=inf, z=inf),
                rotational_restraint=common.Vector3d(x=inf, y=inf, z=inf),
            )
        )

    return objects


def define_loading(snow_load_cases=(2, 3, 4)) -> list:
    """Define and return the loading objects (self-weight + snow arrangement cases).

    EN 1991 generates three duopitch snow arrangements (Case i balanced, Cases
    ii/iii asymmetric), each into its OWN load case - reusing one is rejected
    ("Load cases for generated loads must differ."). The wizard must target the
    same load cases (see define_snow_load_wizard).
    """

    objects = [
        rfem.loading.StaticAnalysisSettings(
            no=1,
            analysis_type=rfem.loading.StaticAnalysisSettings.ANALYSIS_TYPE_GEOMETRICALLY_LINEAR,
        ),
        rfem.loading.LoadCase(
            no=1,
            name="LC1 - Self Weight",
            static_analysis_settings=1,
            action_category=rfem.loading.LoadCase.ActionCategory.ACTION_CATEGORY_PERMANENT_G,
            self_weight_active=True,
        ),
    ]

    # Empty target load cases the Snow Load Wizard generates its arrangements into.
    for i, lc in enumerate(snow_load_cases, start=1):
        objects.append(
            rfem.loading.LoadCase(
                no=lc,
                name=f"LC{lc} - Snow (case {i})",
                static_analysis_settings=1,
            )
        )

    return objects


def define_snow_load_wizard(snow_load_cases=(2, 3, 4)) -> list:
    """Return the duopitch Snow Load Wizard for the roof.

    Corner nodes are given in RFEM's duopitch order - a perimeter walk of the
    folded roof (A B C D E F). The load zone, s_k and altitude are written later
    through the standard-parameters tree (see main), so the wizard is created
    with a placeholder load zone here.
    """

    SnowLoad = rfem.load_wizards.SnowLoad

    # Roof outer-corner nodes (5 nodes per frame: +3 eaves-left, +4 eaves-right,
    # +5 ridge) on the front frame (f = 0) and rear frame (f = NUMBER_OF_FRAMES - 1).
    # Duopitch order is a perimeter walk of the folded roof: A B C D E F.
    rear = (NUMBER_OF_FRAMES - 1) * 5
    roof_corner_nodes = [
        3,          # A front-left eaves
        rear + 3,   # B rear-left eaves
        rear + 5,   # C rear ridge
        rear + 4,   # D rear-right eaves
        4,          # E front-right eaves
        5,          # F front ridge
    ]

    # One row per snow arrangement, each targeting a distinct load case.
    generate_rows = [
        SnowLoad.GenerateIntoLoadCasesRow(no=i, checked=True, load_case=lc)
        for i, lc in enumerate(snow_load_cases, start=1)
    ]

    return [
        SnowLoad(
            no=1,
            user_defined_name_enabled=True,
            name="Snow - Duopitch",
            type=SnowLoad.TYPE_DUOPITCH,
            roof_corner_nodes=roof_corner_nodes,
            definition_type=SnowLoad.DEFINITION_TYPE_USER_DEFINED,
            load_zone=SnowLoad.LOAD_ZONE_TYPE_1,
            generate_into_load_cases=SnowLoad.GenerateIntoLoadCasesTable(rows=generate_rows),
        )
    ]


def snow_load_zone_enum(zone_value: str) -> int:
    """Map a GeoZone snow-zone value (e.g. '1a*') to a SnowLoad.LoadZone enum.

    The enum member names encode the dropdown label, so the label is recovered by
    reversing the naming convention: LOAD_ZONE_TYPE_1_A_ASTERISK -> '1a*'.
    """
    SnowLoad = rfem.load_wizards.SnowLoad
    by_label = {}
    for value in SnowLoad.LoadZone.DESCRIPTOR.values:
        label = (
            value.name.replace("LOAD_ZONE_TYPE_", "")
            .replace("_ASTERISK", "*")
            .replace("_GREATER", "")
            .replace("_A", "a")
            .replace("_", "")
        )
        by_label.setdefault(">" + label if "GREATER" in value.name else label, value.number)
    if zone_value not in by_label:
        raise ValueError(f"GeoZone snow zone {zone_value!r} has no SnowLoad.LoadZone match.")
    return by_label[zone_value]


def select_snow_zone(standards):
    """Pick the snow load-zone standard/annex/layer from a GeoZone standards list."""
    snow_zones = next(
        (group.load_zones for group in standards.type_groups if group.name.lower() == "snow"),
        None,
    )
    if not snow_zones:
        raise ValueError("No snow load-zone group found for the selected country.")
    zone = next(
        (z for z in snow_zones if z.annex.actual and z.standard.name == SNOW_STANDARD_NAME),
        next((z for z in snow_zones if z.annex.actual), snow_zones[0]),
    )
    return zone.standard.name, zone.annex.name, zone.annex.layers[0]


def fetch_snow_load_from_geozone(rfem_app):
    """Query the GeoZone Tool for the site's snow data.

    Returns (geozone_result, s_k, s_k_unit, s_k_pa, load_zone, zone_value). The
    standard-parameters tree stores s_k in SI Pa, so a kN/m2 value is scaled
    x1000; the zone value (e.g. '1a*') is mapped to a SnowLoad.LoadZone enum.
    """
    gzt = geo_zone_tool.GeoZoneTool(token=rfem_app.api_key.value)
    standards = gzt.get_load_zone_standards(
        country_code=GEOZONE_COUNTRY_CODE, language=geo_zone_tool.Language.EN
    )
    standard, annex, layer = select_snow_zone(standards)
    result = gzt.get_load_zone_characteristics(
        address=GEOZONE_ADDRESS,
        load_zone_type=geo_zone_tool.LoadZoneType.SNOW,
        standard=standard,
        annex=annex,
        layer_id=layer.id,
        language=geo_zone_tool.Language.EN,
    )
    sk_var = next(
        (v for ch in result.characteristics
         for v in ch.zone_characteristics.characteristics if v.name == "s_k"),
        None,
    )
    if sk_var is None or sk_var.calculated_value is None:
        raise ValueError("GeoZone response does not contain 's_k'.")
    sk = float(sk_var.calculated_value)
    sk_unit = (sk_var.units_html or "").replace("<sup>", "^").replace("</sup>", "")
    sk_pa = sk * 1000.0 if "kN" in sk_unit else sk
    zone_value = result.characteristics[0].zone_characteristics.zone.value
    load_zone = snow_load_zone_enum(zone_value)
    return result, sk, sk_unit, sk_pa, load_zone, zone_value


# Connect to the RFEM application
with rfem.Application() as rfem_app:

    # Create a clean model
    rfem_app.close_all_models(save_changes=False)
    rfem_app.create_model(name="duopitch_roof_building")
    rfem_app.delete_all_objects()

    # GeoZone Tool: fetch load zone, s_k and the resolved site location for
    # GEOZONE_ADDRESS (token comes from the RFEM API key).
    geozone_result, sk, sk_unit, sk_pa, load_zone, zone_value = fetch_snow_load_from_geozone(rfem_app)
    altitude = float(geozone_result.geo_location.altitude)
    zone_name = rfem.load_wizards.SnowLoad.LoadZone.DESCRIPTOR.values_by_number[load_zone].name
    print(f"\nGeoZone snow query [{GEOZONE_ADDRESS}]:")
    print(f"  load zone        = {zone_value}  ({zone_name})")
    print(f"  zone-derived s_k = {sk:.2f} {sk_unit}  (= {sk_pa:.0f} Pa)")
    print(f"  site altitude    = {altitude:.1f} m")

    # Base Data: activate the load-wizards add-on, assign EN 1991 (DIN), and
    # transfer the resolved site location. Keep the combination wizard OFF so the
    # plain snow load cases are accepted as valid generation targets.
    base_data = rfem_app.get_base_data()
    base_data.addons.load_wizards_active = True
    base_data.addons.combination_wizard_and_classification_active = False
    base_data.standards.load_wizard_standard_group = rfem.BaseData.Standards.LOAD_WIZARD_EN_1991_STANDARD_GROUP
    base_data.standards.load_wizard_standard = rfem.BaseData.Standards.LOAD_WIZARD_NATIONAL_ANNEX_AND_EDITION_EN_1991_DIN_2019_04_STANDARD
    base_data.location.altitude = altitude
    base_data.location.latitude = radians(float(geozone_result.geo_location.latitude))
    base_data.location.longitude = radians(float(geozone_result.geo_location.longitude))
    base_data.location.town_city = geozone_result.geo_location.city
    rfem_app.set_base_data(base_data=base_data)

    # Structure + load cases + Snow Load Wizard.
    rfem_app.create_object_list(define_structure() + define_loading())
    rfem_app.create_object_list(define_snow_load_wizard())

    # Write the GeoZone values into the wizard's standard-parameters tree. s_k
    # only becomes an editable row AFTER manual snow-load definition is enabled,
    # so this is a two-step write:
    #   1. enable manual mode -> update
    #   2. re-read (s_k is now editable) -> set s_k -> update
    snow = rfem_app.get_object(rfem.load_wizards.SnowLoad(no=1))
    params = snow.standard_parameters
    common.set_values_by_key(tree=params, key="load_zone", values=[load_zone])
    common.set_values_by_key(tree=params, key="a", values=[altitude])
    common.set_values_by_key(tree=params, key="manual_snow_load_definition", values=[True])
    common.set_values_by_key(tree=params, key="s_k", values=[sk_pa])
    rfem_app.update_object(rfem.load_wizards.SnowLoad(no=1, standard_parameters=params))