Wind Load Wizard#

../../../../_images/load_wizard_wind.png

Generating duopitch building envelope wind loads with the Wind Load Wizard, letting RFEM create the target load cases:

  • Build a duopitch building envelope from surfaces: two roof slopes and four walls

  • Query the GeoZone Tool for the site’s basic wind velocity v_b,0

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

  • Create the wizard while the model still has no load cases, since it binds to any that already exist

  • Set generate_missing_load_cases, the GUI “Generate Load Cases” button, and let RFEM work out the twelve arrangements and name a load case after each

  • Write the GeoZone wind velocity into the wizard’s standard-parameters tree

  • Add the self-weight case behind the generated ones, at the first free number

  • The opposite strategy, preparing the load cases first and assigning them through generate_into_load_cases, works here too and is shown in Snow Load Wizard

Keywords:
wind load wizard duopitch roof building envelope GeoZone Tool generate_missing_load_cases EN 1991-1-4 free polygon load
"""Duopitch building envelope with a Wind Load Wizard that creates its own load cases.

The target load cases are NOT prepared by the script. The wizard is created with
`generate_missing_load_cases = True` (the GUI "Generate Load Cases" button), and
RFEM works out how many arrangements the standard produces, creates one load case per
arrangement - named after its wind direction and case, e.g.
"Wind Perpendicular to Wall 2 (B-C-D) | 270 deg | Case w+" - and generates the
loads into them.

The opposite strategy - preparing the load cases first and handing their numbers
to the wizard through `generate_into_load_cases` - works here too, and is what
load_wizard_snow.py does. Use it when the load-case numbering has to match an
existing model. Both wizards accept both strategies; the two examples show one
each rather than repeating the same script twice.

The geometry follows the published worked example Access Steel SX016a-EN-EU,
"Example: Determination of loads on a building envelope", M. Oppe / C. Mueller,
June 2005 (EN 1991-1-3, EN 1991-1-4):

    Total length,  b     = 72.000 m   (ridge direction, global Y)
    Bay width,     d     = 30.000 m   (span across the slopes, global X)
    Eaves height,  h'    =  5.988 m
    Ridge height,  h     =  7.300 m
    Roof slope,    alpha =  5.0 deg

Auto-generated versus prepared load cases
-----------------------------------------
Letting the wizard create them is the shorter route: nothing has to be known in
advance about how many arrangements EN 1991 produces for a duopitch building
(twelve here - four per ridge-normal direction per Table 7.4a Note 1, two per
ridge-parallel direction), and the load cases come out labelled by direction
instead of by a hand-written comment. Preparing twelve load cases by hand, in the
order the wizard expects, is the cost the other strategy carries.

Two properties of the auto-generation drive the order of this script:

    1. It numbers the new load cases from 1 upwards and does NOT skip load cases
       that already exist - it generates INTO them. With a self-weight LC1 in the
       model beforehand, the first wind arrangement lands on top of the
       self-weight case. So the wizard is created FIRST, while the model has no
       load cases at all, and the self-weight case is added afterwards with the
       next free number (13 here, read back from the wizard).

    2. Static analysis settings are needed before the wizard is created - the
       generated load cases are given the existing settings object, and a load
       case without analysis settings is not a valid generation target.

`generate_missing_load_cases` is a one-shot trigger, not stored state: it reads
back as False once the load cases have been made. Re-sending it in update_object
generates a further set of load cases, it does not refresh the existing ones -
changing a standard parameter is enough for that (see the v_b,0 write below).

Base Data assigns EN 1991 with the base CEN annex, which works from the EN
recommended values. The basic wind velocity v_b,0 for GEOZONE_ADDRESS is fetched
from the GeoZone Tool and written into the wizard's standard-parameters tree (the
GUI "Parameters" tab), mirroring load_wizard_snow.py for the snow load s_k. Under
the CEN annex v_b,0 is a direct input - there are no wind zones - so this write is
what sets the velocity pressure q_b = 0.5 * rho * v_b,0^2, and RFEM regenerates
the loads in the already-created load cases.

Roof corner nodes must be given in RFEM's duopitch order - a perimeter walk of
the folded roof (the wizard's A-F diagram), as in the Snow Load Wizard - plus
the four ground corners under the eaves in the same order:

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

The combination wizard must be OFF, or plain load cases are not accepted as
generation targets and the wizard is created without any loads appearing. A
National Annex that works from wind zones needs a real load_zone as well, because
a wizard left on LOAD_ZONE_NOT_DEFINED cannot be passed back into update_object;
the CEN annex has no wind zones, so none is set here.

The reference sheet assumes terrain category II, giving q_p = 0.911 kN/m2 at
z = 7.30 m. This wizard defaults to terrain category 0 (k_r = 0.1560,
z_0 = 0.003 m). The tree exposes k_r behind `manual_definition_of_terrain_factor`
but not z_0, so a consistent terrain category cannot be set from the API and the
generated velocity pressure differs from the sheet.

Units are SI: metres for geometry, Pascals for pressures. 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 radians, tan

# -------------------------------------------------------------------------
# Editable parameters (SI units) - the SX016a building
# -------------------------------------------------------------------------
BUILDING_LENGTH = 72.0            # m, b, ridge direction (global Y)
BUILDING_WIDTH = 30.0             # m, d, span across the slopes (global X)
EAVES_HEIGHT = 5.988              # m, h', ground to eaves
ROOF_PITCH = 5.0                  # deg, alpha

MATERIAL = "S235 | EN 1993-1-1:2005-05"   # steel, from the RFEM material library
SHELL_THICKNESS = 0.2                     # m, envelope surfaces

# GeoZone Tool: the basic wind velocity v_b,0 for this site is fetched from the
# GeoZone service and written into the wizard's standard-parameters tree (same
# pattern as load_wizard_snow.py, which transfers s_k for snow).
GEOZONE_ADDRESS = "Aachen, Germany"
GEOZONE_COUNTRY_CODE = "DE"
WIND_STANDARD_NAME = "EN 1991-1-4"   # preferred wind standard from the GeoZone list


def ridge_height() -> float:
    """Ridge height h from the eaves height and the roof pitch (SX016a sheet 1)."""
    return EAVES_HEIGHT + (BUILDING_WIDTH / 2.0) * tan(radians(ROOF_PITCH))


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

    Nodes 1-4 are the eaves corners, 5-6 the ridge ends and 7-10 the ground
    corners. Six surfaces close the envelope: two roof slopes, two side walls
    and two gable walls. SX016a is an envelope example, so no frames are needed -
    the wizard loads the surfaces directly.

    Static analysis settings are part of this list because they have to exist
    before the wizard runs: the load cases it creates are given these settings,
    and a load case without them is not a valid generation target. No load case
    is defined here - the wizard makes them all (see the module docstring).
    """

    length, width, ridge = BUILDING_LENGTH, BUILDING_WIDTH, ridge_height()

    objects = [
        rfem.structure_core.Material(no=1, name=MATERIAL),
        rfem.structure_core.Thickness(
            no=1,
            material=1,
            uniform_thickness=SHELL_THICKNESS,
            type=rfem.structure_core.Thickness.TYPE_UNIFORM,
        ),

        # --- Nodes: eaves (1-4), ridge (5-6), ground (7-10). Z is negative for
        # structural height. ---
        rfem.structure_core.Node(no=1, coordinate_1=0.0, coordinate_2=0.0, coordinate_3=-EAVES_HEIGHT),
        rfem.structure_core.Node(no=2, coordinate_1=0.0, coordinate_2=length, coordinate_3=-EAVES_HEIGHT),
        rfem.structure_core.Node(no=3, coordinate_1=width, coordinate_2=0.0, coordinate_3=-EAVES_HEIGHT),
        rfem.structure_core.Node(no=4, coordinate_1=width, coordinate_2=length, coordinate_3=-EAVES_HEIGHT),
        rfem.structure_core.Node(no=5, coordinate_1=width / 2.0, coordinate_2=0.0, coordinate_3=-ridge),
        rfem.structure_core.Node(no=6, coordinate_1=width / 2.0, coordinate_2=length, coordinate_3=-ridge),
        rfem.structure_core.Node(no=7, coordinate_1=0.0, coordinate_2=0.0, coordinate_3=0.0),
        rfem.structure_core.Node(no=8, coordinate_1=0.0, coordinate_2=length, coordinate_3=0.0),
        rfem.structure_core.Node(no=9, coordinate_1=width, coordinate_2=length, coordinate_3=0.0),
        rfem.structure_core.Node(no=10, coordinate_1=width, coordinate_2=0.0, coordinate_3=0.0),
    ]

    # --- Lines: boundaries of the six envelope surfaces ---
    boundaries = {
        1: [1, 2], 2: [2, 6], 3: [6, 5], 4: [5, 1],           # roof, left slope
        5: [3, 4], 6: [4, 6], 7: [6, 5], 8: [5, 3],           # roof, right slope
        9: [7, 8], 10: [8, 2], 11: [2, 1], 12: [1, 7],        # side wall, X = 0
        13: [10, 9], 14: [9, 4], 15: [4, 3], 16: [3, 10],     # side wall, X = d
        17: [7, 10], 18: [10, 3], 19: [3, 5], 20: [5, 1], 21: [1, 7],   # gable, Y = 0
        22: [8, 9], 23: [9, 4], 24: [4, 6], 25: [6, 2], 26: [2, 8],     # gable, Y = b
    }
    for no, nodes in boundaries.items():
        objects.append(rfem.structure_core.Line(no=no, definition_nodes=nodes))

    # --- Surfaces: the planes the wind pressures are generated on ---
    objects += [
        rfem.structure_core.Surface(no=1, geometry=1, boundary_lines=[1, 2, 3, 4], thickness=1),
        rfem.structure_core.Surface(no=2, geometry=1, boundary_lines=[5, 6, 7, 8], thickness=1),
        rfem.structure_core.Surface(no=3, geometry=1, boundary_lines=[9, 10, 11, 12], thickness=1),
        rfem.structure_core.Surface(no=4, geometry=1, boundary_lines=[13, 14, 15, 16], thickness=1),
        rfem.structure_core.Surface(no=5, geometry=1, boundary_lines=[17, 18, 19, 20, 21], thickness=1),
        rfem.structure_core.Surface(no=6, geometry=1, boundary_lines=[22, 23, 24, 25, 26], thickness=1),
    ]

    # --- Analysis settings for the load cases the wizard is about to create ---
    objects.append(
        rfem.loading.StaticAnalysisSettings(
            no=1,
            analysis_type=rfem.loading.StaticAnalysisSettings.ANALYSIS_TYPE_GEOMETRICALLY_LINEAR,
        )
    )

    return objects


def define_wind_load_wizard() -> list:
    """Return the walls + duopitch-roof Wind Load Wizard for the envelope.

    `generate_missing_load_cases = True` is what makes RFEM create the target load
    cases itself, so no `generate_into_load_cases` table is passed. The flag acts once,
    when the wizard is created, and reads back as False afterwards.

    Roof corner nodes follow RFEM's duopitch order - a perimeter walk of the
    folded roof (A B C D E F) - and the base corner nodes are the four ground
    corners in the same walk order. No load zone is passed: the CEN annex has no
    wind zones and takes v_b,0 directly, which is written afterwards through the
    standard-parameters tree (see main).
    """

    return [
        rfem.load_wizards.WindLoad(
            no=1,
            user_defined_name_enabled=True,
            name="Wind - Walls + Duopitch Roof",
            type=rfem.load_wizards.WindLoad.TYPE_WALLS_ROOF_DUOPITCH,
            structure_definition=rfem.load_wizards.WindLoad.STRUCTURE_DEFINITION_BUILDINGS,
            roof_corner_nodes=[
                1,   # A front-left eaves
                2,   # B rear-left eaves
                6,   # C rear ridge
                4,   # D rear-right eaves
                3,   # E front-right eaves
                5,   # F front ridge
            ],
            base_corner_nodes=[7, 8, 9, 10],
            # USER_DEFINED is required. The default PARAMETERS_FROM_MAP reads the terrain
            # category from a map location, so without an address that parameter stays
            # "not defined", the wizard is invalid for generation and produces no loads at
            # all - without any error.
            definition_type=rfem.load_wizards.WindLoad.DEFINITION_TYPE_USER_DEFINED,
            generate_missing_load_cases=True,
        )
    ]


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


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

    Returns (geozone_result, v_b_0, zone_value). The standard-parameters tree
    stores v_b,0 in SI m/s, which is what GeoZone reports, so no scaling is
    needed; zone_value (e.g. '2') is the wind zone the value was read from and is
    reported for information.
    """
    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_wind_zone(standards)
    result = gzt.get_load_zone_characteristics(
        address=GEOZONE_ADDRESS,
        load_zone_type=geo_zone_tool.LoadZoneType.WIND,
        standard=standard,
        annex=annex,
        layer_id=layer.id,
        language=geo_zone_tool.Language.EN,
    )
    vb0_var = next(
        (v for ch in result.characteristics
         for v in ch.zone_characteristics.characteristics if v.name == "v_b0"),
        None,
    )
    if vb0_var is None or not vb0_var.calculated_value:
        raise ValueError("GeoZone response does not contain 'v_b0'.")
    zone_value = result.characteristics[0].zone_characteristics.zone.value
    return result, float(vb0_var.calculated_value), 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_wind_loads_generated_lcs")
    rfem_app.delete_all_objects()

    # GeoZone Tool: fetch v_b,0 and the resolved site location for GEOZONE_ADDRESS
    # (token comes from the RFEM API key).
    geozone_result, v_b_0, zone_value = fetch_wind_load_from_geozone(rfem_app)
    altitude = float(geozone_result.geo_location.altitude)
    print(f"\nGeoZone wind query [{GEOZONE_ADDRESS}]:")
    print(f"  wind zone         = {zone_value}")
    print(f"  zone-derived v_b0 = {v_b_0:.1f} m/s"
          f"  (-> q_b = {0.5 * 1.25 * v_b_0 ** 2:.1f} Pa)")
    print(f"  site altitude     = {altitude:.1f} m")

    # Base Data: activate the load-wizards add-on, assign EN 1991 with the base CEN
    # annex, and transfer the resolved site location. Keep the combination wizard
    # OFF so the generated 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_CEN_2015_09_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)

    # Envelope and analysis settings, then the wizard - which creates one load case
    # per wind arrangement and generates the loads into them.
    rfem_app.create_object_list(define_structure())
    rfem_app.create_object_list(define_wind_load_wizard())

    # Write the GeoZone v_b,0 into the wizard's standard-parameters tree. Under the
    # CEN annex v_b,0 is a plain input row, so a single update is enough and RFEM
    # recomputes q_b and regenerates the loads in the load cases it already made.
    # National Annexes that derive v_b,0 from a wind zone gate the row behind
    # `manual_definition_of_wind_velocity`, and there the flag and the value need
    # two separate updates.
    wind = rfem_app.get_object(rfem.load_wizards.WindLoad(no=1))
    params = wind.standard_parameters
    common.set_values_by_key(tree=params, key="v_b_0", values=[v_b_0])
    rfem_app.update_object(rfem.load_wizards.WindLoad(no=1, standard_parameters=params))

    # Add the self-weight case behind the generated ones. Its number is the first
    # free one after the arrangements the wizard made - read back from the wizard
    # rather than assumed, because how many arrangements the standard produces
    # depends on the wizard type and the National Annex.
    generated = rfem_app.get_object(
        rfem.load_wizards.WindLoad(no=1)
    ).generate_into_load_cases.rows
    self_weight_lc = max(row.load_case for row in generated) + 1
    rfem_app.create_object(
        rfem.loading.LoadCase(
            no=self_weight_lc,
            name="Self Weight",
            static_analysis_settings=1,
            action_category=rfem.loading.LoadCase.ActionCategory.ACTION_CATEGORY_PERMANENT_G,
            self_weight_active=True,
        )
    )

    print(f"\nWizard generated {len(generated)} wind load cases, "
          f"self weight added as LC{self_weight_lc}:")
    for lc in rfem_app.get_object_list([rfem.loading.LoadCase()]):
        print(f"  LC{lc.no} - {lc.name}")