Skip to content

Commit 283eb3d

Browse files
authored
Merge pull request #20 from zsarnoczay/enhance_Pelicun_conversion
fix(input_builder): add unit conversion and replacement time scaling to Pelicun converter
2 parents ee8c54d + e744904 commit 283eb3d

2 files changed

Lines changed: 87 additions & 10 deletions

File tree

‎pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ test = ["pytest>=7.0"]
3838
[tool.setuptools.packages.find]
3939
where = ["src"]
4040

41+
[tool.setuptools.package-data]
42+
atc138 = ["data/*"]
43+
4144
[tool.pytest.ini_options]
4245
pythonpath = ["tests"]
4346
markers = [

‎src/atc138/input_builder.py‎

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,36 @@ def save_json(data, path):
503503
with open(path, "w") as f:
504504
json.dump(data, f, indent=2)
505505

506+
# SI conversion factors for units relevant to FEMA P-58 components.
507+
# To convert from unit_a to unit_b: value_b = value_a * (factor_a / factor_b)
508+
_UNIT_TO_SI = {
509+
# Area (to m²)
510+
'sf': 0.09290304, 'SF': 0.09290304, 'ft2': 0.09290304, 'm2': 1.0,
511+
# Length (to m)
512+
'lf': 0.3048, 'LF': 0.3048, 'ft': 0.3048, 'm': 1.0,
513+
# Count (unitless)
514+
'ea': 1.0, 'EA': 1.0, 'each': 1.0,
515+
}
516+
517+
518+
def _pelicun_to_p58_factor(pelicun_unit, atc138_unit, unit_qty):
519+
"""Compute factor to convert a Pelicun physical quantity to P-58 unit count.
520+
521+
value_in_p58_units = value_from_pelicun * factor
522+
523+
When both units belong to the same physical dimension (area, length, or
524+
count), the factor converts between unit systems and then divides by the
525+
P-58 block size (unit_qty). When the dimensions are incompatible (e.g.
526+
Pelicun reports EA but ATC-138 expects amp/ton/cfm), the Pelicun value
527+
is assumed to already represent a P-58 unit count and the factor is 1.0.
528+
"""
529+
p_si = _UNIT_TO_SI.get(pelicun_unit)
530+
a_si = _UNIT_TO_SI.get(atc138_unit)
531+
if p_si is not None and a_si is not None:
532+
return (p_si / a_si) / unit_qty
533+
return 1.0
534+
535+
506536
def clean_frag_id(frag_id: str) -> str:
507537
"""
508538
Splits by ".",
@@ -565,6 +595,10 @@ def story_mask(location, num_stories):
565595
start, end = map(int, location.split("--"))
566596
return (stories >= start) & (stories <= end)
567597

598+
if ',' in location:
599+
loc_vec = np.array(location.split(','), dtype=int)
600+
return np.isin(stories, loc_vec)
601+
568602
loc_vec = np.array(location.split(), dtype=int)
569603
return np.isin(stories, loc_vec)
570604

@@ -620,6 +654,8 @@ def convert_pelicun(model_dir):
620654
# ds attributes
621655
static_data_dir = os.path.join(os.path.dirname(__file__), 'data')
622656
ds_attributes = load_custom_static_tables(model_dir, static_data_dir, 'damage_state_attribute_mapping.csv')
657+
component_attributes = load_custom_static_tables(
658+
model_dir, static_data_dir, 'component_attributes.csv')
623659

624660
# general inputs
625661
with open(os.path.join(model_dir, 'general_inputs.json')) as file:
@@ -710,18 +746,39 @@ def convert_pelicun(model_dir):
710746
else:
711747
eng_cost_ratio = general_inputs['engineering_cost_ratio']
712748

749+
# Convert Pelicun worker-days to calendar days for replacement time.
750+
# Use the same max-workers formula as the repair scheduler, but
751+
# cap the effective area at a limited number of parallel floors to consider
752+
# that construction proceeds upward — a 40-story building will not have all
753+
# floors staffed simultaneously.
754+
_MAX_PARALLEL_FLOORS = 6
755+
_default_rto = json.load(
756+
open(os.path.join(static_data_dir, 'default_inputs.json'))
757+
)['repair_time_options']
758+
_wkr_per_sf = _default_rto['max_workers_per_sqft_building']
759+
_wkr_min = _default_rto['max_workers_building_min']
760+
_wkr_max = _default_rto['max_workers_building_max']
761+
762+
_effective_floors = min(num_stories, _MAX_PARALLEL_FLOORS)
763+
_effective_area = _effective_floors * plan_area
764+
_max_workers = min(
765+
max(int(_effective_area * _wkr_per_sf + 10), _wkr_min),
766+
_wkr_max,
767+
)
768+
_replacement_time_days = np.where(
769+
replacement_mask,
770+
DV_summary["repair_time-parallel"] / _max_workers,
771+
np.nan,
772+
)
773+
713774
damage_consequences = dict(
714775
repair_cost_ratio_total=(
715776
DV_summary["repair_cost-"] / total_cost
716777
).tolist(),
717778
repair_cost_ratio_engineering=(
718779
DV_summary["repair_cost-"] / total_cost * eng_cost_ratio
719780
).tolist(),
720-
simulated_replacement_time=np.where(
721-
replacement_mask,
722-
DV_summary["repair_time-parallel"],
723-
np.nan,
724-
).tolist(),
781+
simulated_replacement_time=_replacement_time_days.tolist(),
725782
)
726783

727784
save_json(
@@ -809,6 +866,22 @@ def convert_pelicun(model_dir):
809866
comps = comps[comps['tmp_clean_id'].isin(list(comp_ds_list['comp_id']))]
810867
comps = comps.drop('tmp_clean_id', axis=1)
811868

869+
# Build per-component conversion factor: Pelicun physical qty -> P-58 units
870+
comp_conversion = {}
871+
for _, _comp in comps.iterrows():
872+
_fid = clean_frag_id(_comp["ID"])
873+
_pelicun_unit = str(_comp["Units"]).strip()
874+
_attr_match = component_attributes[
875+
component_attributes['fragility_id'] == _fid
876+
]
877+
if _attr_match.empty:
878+
comp_conversion[_fid] = 1.0
879+
continue
880+
_attr = _attr_match.iloc[0]
881+
comp_conversion[_fid] = _pelicun_to_p58_factor(
882+
_pelicun_unit, str(_attr['unit']).strip(), float(_attr['unit_qty'])
883+
)
884+
812885
for _, comp in comps.iterrows():
813886

814887
# clean periods from ID, then replace with underscore to match convention
@@ -831,7 +904,8 @@ def convert_pelicun(model_dir):
831904
& np.isin(comp_population.index.get_level_values("dir"), dir_vec)
832905
)
833906

834-
comp_population.loc[mask, frag_id] += float(comp["Theta_0"])
907+
_cfid = clean_frag_id(comp['ID'])
908+
comp_population.loc[mask, frag_id] += float(comp["Theta_0"]) * comp_conversion.get(_cfid, 1.0)
835909

836910
comp_population.reset_index().to_csv(
837911
os.path.join(model_dir, "comp_population.csv"),
@@ -928,7 +1002,8 @@ def convert_pelicun(model_dir):
9281002
idx = lookup_index[key]
9291003

9301004
dmg_data = np.array(damage[col].fillna(0), dtype=float)
931-
simulated_damage["story"][story]["qnt_damaged"][:, idx] += dmg_data
1005+
qnt_factor = comp_conversion.get(frag_id, 1.0)
1006+
simulated_damage["story"][story]["qnt_damaged"][:, idx] += dmg_data * qnt_factor
9321007

9331008
if col in DV_time.columns:
9341009
repair_time_data = DV_time[col].fillna(0).astype(float)
@@ -947,8 +1022,7 @@ def convert_pelicun(model_dir):
9471022
# Direction-specific
9481023
simulated_damage["story"][story][
9491024
f"qnt_damaged_dir_{dir_id}"
950-
][:, idx] += dmg_data
951-
1025+
][:, idx] += dmg_data * qnt_factor
9521026

9531027
# add component quantity to simulated_damage
9541028
for _, comp in comps.iterrows():
@@ -964,7 +1038,7 @@ def convert_pelicun(model_dir):
9641038
if not is_story:
9651039
continue
9661040

967-
simulated_damage["story"][s_idx]["num_comps"][frag_matches] += theta
1041+
simulated_damage["story"][s_idx]["num_comps"][frag_matches] += theta * comp_conversion.get(frag_id, 1.0)
9681042

9691043
############# convert to json-serializable and save
9701044
# Convert numpy arrays to lists

0 commit comments

Comments
 (0)