Snow Load Wizard#
|
Generating duopitch roof snow loads from GeoZone site data with the Snow Load Wizard:
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))