MaterialInverseDesignDemo / assets /quarter_sim /generate_data_mp_version_2.py
feiyang-cai's picture
Initial ZeroGPU Gradio Space
01a8278
Raw
History Blame Contribute Delete
49.4 kB
#!/usr/bin/env python3
"""
Multiprocessing data generation script for laminate simulations.
Loads configuration from YAML file and generates data using multiprocessing.
"""
import yaml
import numpy as np
from pathlib import Path
from itertools import product, combinations_with_replacement, combinations
from math import comb, ceil, factorial
from collections import Counter
import multiprocessing as mp
from functools import partial
import sys
from tqdm import tqdm
from datetime import datetime
import shutil
import json
import matplotlib.pyplot as plt
# Import from lam.py (don't modify original)
#lam_path = Path(__file__).parent
lam_path = Path(__file__).parent
sys.path.insert(0, str(lam_path))
# Import lam module (version from config, default "lam.py")
import importlib.util
spec = importlib.util.spec_from_file_location("lam", lam_path / "lam2.py")
lam = importlib.util.module_from_spec(spec)
spec.loader.exec_module(lam)
# Import needed functions and classes
read_dataset_metadata = lam.read_dataset_metadata
load_ud_material_from_files = lam.load_ud_material_from_files
build_full_symmetric_stack = lam.build_full_symmetric_stack
stack_label_from_upper = lam.stack_label_from_upper
Ply = lam.Ply
Laminate = lam.Laminate
T_eps = lam.T_eps
T_sigma = lam.T_sigma
T = lam.T
orthotropic_C_prime = lam.orthotropic_C_prime
COND_MAX = lam.COND_MAX
def parse_discrete_value(value):
"""
Parse discrete value from YAML.
Can be:
- A list: [1, 2, 3]
- A dict with start, end, interval: {start: 0, end: 90, interval: 10}
"""
if isinstance(value, list):
return value
elif isinstance(value, dict):
if 'start' in value and 'end' in value and 'interval' in value:
start = float(value['start'])
end = float(value['end'])
interval = float(value['interval'])
# Generate list from start to end (inclusive) with given interval
result = []
current = start
while current <= end + 1e-9: # Add small epsilon for floating point
result.append(current)
current += interval
return result
else:
raise ValueError(f"Discrete dict must have 'start', 'end', and 'interval' keys")
else:
raise ValueError(f"Discrete value must be a list or dict, got {type(value)}")
def parse_continuous_value(value):
"""
Parse continuous value from YAML.
Must be a dict with 'num_samples', 'min', 'max'.
"""
if isinstance(value, dict):
if 'num_samples' in value and 'min' in value and 'max' in value:
num_samples = int(value['num_samples'])
min_val = float(value['min'])
max_val = float(value['max'])
return {
'num_samples': num_samples,
'min': min_val,
'max': max_val
}
else:
raise ValueError(f"Continuous value must have 'num_samples', 'min', and 'max' keys")
else:
raise ValueError(f"Continuous value must be a dict, got {type(value)}")
def make_sample(material_type, vol_fraction, quarter_angles):
"""
Build one canonical sample dictionary for the balanced-symmetric quarter-angle workflow.
Parameters
----------
material_type : str
vol_fraction : str | float
quarter_angles : sequence of numbers
Positive-angle quarter block. Order does not matter.
Returns
-------
dict
Sample dictionary with the new schema:
- material_type
- vol_fraction
- quarter_layer_count
- quarter_angles
- half_layer_count
- half_angles
- center_angle
- full_layer_count
- full_angles
- unique_angle_count
- unique_angle_family
"""
ordered_quarter_angles = tuple(sorted(float(a) for a in quarter_angles))
half_angles = tuple(build_half_from_quarter_angles(ordered_quarter_angles))
full_angles = tuple(build_full_from_quarter_angles(ordered_quarter_angles))
unique_angle_family = tuple(sorted({abs(float(a)) for a in ordered_quarter_angles}))
return {
'material_type': material_type,
'vol_fraction': vol_fraction,
'quarter_layer_count': len(ordered_quarter_angles),
'quarter_angles': list(ordered_quarter_angles),
'half_layer_count': len(half_angles),
'half_angles': list(half_angles),
'center_angle': None,
'full_layer_count': len(full_angles),
'full_angles': list(full_angles),
'unique_angle_count': len(unique_angle_family),
'unique_angle_family': unique_angle_family,
}
def parse_unique_k_proportions(config, max_unique_k):
"""
Parse proportion weights for active feasible family sizes only.
Important behavior:
- Keys 1..max_unique_k are required.
- Keys > max_unique_k are allowed and ignored.
- Values are non-negative weights (not required to sum to 1).
"""
proportion_cfg = config.get('unique_angle_k_proportions')
if not isinstance(proportion_cfg, dict) or not proportion_cfg:
raise ValueError(
"Config must define 'unique_angle_k_proportions' "
"as a non-empty dictionary."
)
for key in proportion_cfg.keys():
try:
key_int = int(key)
except (TypeError, ValueError):
raise ValueError(
f"Invalid family key '{key}' in "
"'unique_angle_k_proportions'. "
"Keys must be integers like 1, 2, 3, ..."
)
if key_int < 1:
raise ValueError(
f"Invalid family key '{key}'. Family sizes must be >= 1."
)
proportions = {}
for k in range(1, max_unique_k + 1):
raw_value = proportion_cfg.get(k, proportion_cfg.get(str(k)))
if raw_value is None:
raise ValueError(
f"Missing proportion for active family size k={k} "
f"in 'unique_angle_k_proportions'."
)
value = float(raw_value)
if value < 0.0:
raise ValueError(
f"Proportion weight for k={k} must be >= 0, got {value}."
)
proportions[k] = value
if sum(proportions.values()) <= 0.0:
raise ValueError(
"At least one active k in 'unique_angle_k_proportions' must have a positive value."
)
return proportions
def same_angle(a, b, tol=1e-9):
return abs(float(a) - float(b)) < tol
def is_self_balanced_angle(angle, tol=1e-9):
"""
Angles that are self-balanced under sign reversal for this workflow.
"""
a = abs(float(angle)) % 180.0
return abs(a - 0.0) < tol or abs(a - 90.0) < tol
def balance_partner(angle):
"""
Canonical balance partner for the quarter-angle workflow.
Off-axis angles map to their sign-reversed partner.
Self-balanced angles (0 and 90) map to themselves.
"""
a = float(angle)
if is_self_balanced_angle(a):
a_mod = abs(a) % 180.0
if same_angle(a_mod, 0.0):
return 0.0
if same_angle(a_mod, 90.0):
return 90.0
return a_mod
return -a
def build_half_from_quarter_angles(quarter_angles):
"""
Build the ordered half-stack from an unordered quarter-angle multiset.
"""
ordered_quarter = tuple(sorted(float(a) for a in quarter_angles))
mirrored_quarter = tuple(balance_partner(a) for a in reversed(ordered_quarter))
return ordered_quarter + mirrored_quarter
def build_full_from_quarter_angles(quarter_angles):
"""
Build the full balanced-symmetric stack from quarter angles.
"""
half_angles = build_half_from_quarter_angles(quarter_angles)
return tuple(build_full_symmetric_stack(half_angles, center_angle=None))
def count_distinct_quarter_angles(quarter_angles):
"""
Number of distinct quarter-angle families in the quarter multiset.
"""
return len({float(a) for a in quarter_angles})
def iter_positive_compositions(total, parts):
"""
Yield all positive compositions of `total` into `parts` strictly positive integers.
"""
if parts < 1 or total < parts:
return
if parts == 1:
yield (total,)
return
for cuts in combinations(range(1, total), parts - 1):
prev = 0
comp = []
for cut in cuts:
comp.append(cut - prev)
prev = cut
comp.append(total - prev)
yield tuple(comp)
def sample_positive_composition(total, parts, rng):
"""
Uniformly sample one positive composition of `total` into `parts` parts.
"""
if parts < 1 or total < parts:
raise ValueError(f"Cannot compose total={total} into parts={parts}.")
if parts == 1:
return (total,)
cuts = sorted(rng.choice(np.arange(1, total), size=parts - 1, replace=False).tolist())
prev = 0
comp = []
for cut in cuts:
comp.append(cut - prev)
prev = cut
comp.append(total - prev)
return tuple(comp)
def generate_parameter_space(config):
"""
Generate all parameter combinations for the balanced-symmetric quarter-angle workflow.
Workflow:
- material_types: exhaustive pool
- vol_fractions: exhaustive supplied VF cases
- candidate_angles: finite pool of positive quarter-angle candidates
- quarter_layer_counts: allowed quarter-block sizes
- total_samples: requested final sample count
- unique_angle_k_proportions:
relative weights for each unique-angle family size k in final dataset
Returns:
- List of sample dicts
- Total number of samples
- Count breakdown by k
- Allocation report dictionary
"""
workflow = config.get('sampling_workflow', 'family_percentage_exhaustive_mat_vf')
if workflow != 'family_percentage_exhaustive_mat_vf':
raise ValueError(
"This version of generate_parameter_space only supports "
"sampling_workflow='family_percentage_exhaustive_mat_vf'."
)
# ------------------------------------------------------------------
# Parse exhaustive outer-loop variables
# ------------------------------------------------------------------
material_types = config['material_types']
vf_config = config.get('vol_fractions', {})
if isinstance(vf_config, dict) and 'values' in vf_config:
vol_fractions = parse_discrete_value(vf_config['values'])
else:
vol_fractions = parse_discrete_value(vf_config)
if not vol_fractions:
raise ValueError("vol_fractions cannot be empty.")
# ------------------------------------------------------------------
# Parse candidate quarter-angle pool
# ------------------------------------------------------------------
angles_config = config.get('candidate_angles', {})
if isinstance(angles_config, dict) and 'type' in angles_config:
if angles_config['type'] != 'discrete':
raise ValueError(
"In the balanced-symmetric quarter-angle workflow, "
"candidate_angles must define a finite angle pool."
)
candidate_angles = parse_discrete_value(angles_config['values'])
else:
candidate_angles = parse_discrete_value(angles_config)
if not candidate_angles:
raise ValueError("candidate_angles cannot be empty.")
candidate_angles = sorted(float(a) for a in candidate_angles)
if len(set(candidate_angles)) != len(candidate_angles):
raise ValueError(
"candidate_angles must contain unique values for the quarter-angle workflow."
)
# ------------------------------------------------------------------
# Parse allowed quarter-block sizes
# ------------------------------------------------------------------
raw_quarter_layer_counts = parse_discrete_value(config.get('quarter_layer_counts', [1]))
quarter_layer_counts = sorted(int(n) for n in raw_quarter_layer_counts)
if not quarter_layer_counts:
raise ValueError("quarter_layer_counts cannot be empty.")
if any(n < 1 for n in quarter_layer_counts):
raise ValueError("All quarter_layer_counts must be >= 1.")
max_quarter_layers = max(quarter_layer_counts)
# ------------------------------------------------------------------
# Determine maximum unique-angle family size
# ------------------------------------------------------------------
proportion_cfg = config.get('unique_angle_k_proportions', {})
if not isinstance(proportion_cfg, dict) or not proportion_cfg:
raise ValueError(
"Config must define 'unique_angle_k_proportions' "
"as a non-empty dictionary."
)
try:
requested_k_values = sorted(int(k) for k in proportion_cfg.keys())
except (TypeError, ValueError):
raise ValueError(
"'unique_angle_k_proportions' keys must be integers like 1, 2, 3, ..."
)
if not requested_k_values or requested_k_values[0] < 1:
raise ValueError(
"'unique_angle_k_proportions' must start from k=1."
)
max_unique_k_requested = max(requested_k_values)
max_unique_k_allowed = min(max_quarter_layers, len(candidate_angles))
active_max_unique_k = min(max_unique_k_requested, max_unique_k_allowed)
ignored_k_values = [k for k in requested_k_values if k > active_max_unique_k]
if active_max_unique_k < 1:
raise ValueError(
"No feasible unique-angle family sizes are available. "
"Check candidate_angles and quarter_layer_counts."
)
proportion_by_k = parse_unique_k_proportions(config, active_max_unique_k)
total_samples_requested = config.get('total_samples')
if total_samples_requested is None:
raise ValueError(
"Config must define 'total_samples' for workflow 'family_percentage_exhaustive_mat_vf'."
)
total_samples_requested = int(total_samples_requested)
if total_samples_requested < 1:
raise ValueError("total_samples must be >= 1.")
# ------------------------------------------------------------------
# Reproducibility
# ------------------------------------------------------------------
random_seed = config.get('random_seed')
rng = np.random.default_rng(random_seed)
# ------------------------------------------------------------------
# Capacity by k
# ------------------------------------------------------------------
mat_vf_multiplier = len(material_types) * len(vol_fractions)
capacity_by_k = {k: 0 for k in range(1, active_max_unique_k + 1)}
capacity_by_k_and_q = {k: {} for k in range(1, active_max_unique_k + 1)}
for k in range(1, active_max_unique_k + 1):
for q in quarter_layer_counts:
if k > q:
continue
quarter_multiset_count = comb(len(candidate_angles), k) * comb(q - 1, k - 1)
capacity_qk = mat_vf_multiplier * quarter_multiset_count
if capacity_qk <= 0:
continue
capacity_by_k[k] += capacity_qk
capacity_by_k_and_q[k][q] = capacity_qk
total_capacity = sum(capacity_by_k.values())
normalized_total = sum(proportion_by_k.values())
normalized_proportion_by_k = {
k: (proportion_by_k[k] / normalized_total) for k in range(1, active_max_unique_k + 1)
}
# Initial requested targets by k via largest remainder method
raw_targets = {
k: total_samples_requested * normalized_proportion_by_k[k]
for k in range(1, active_max_unique_k + 1)
}
requested_target_by_k = {k: int(raw_targets[k]) for k in raw_targets}
fractional_order = sorted(
range(1, active_max_unique_k + 1),
key=lambda k: (raw_targets[k] - requested_target_by_k[k], normalized_proportion_by_k[k], -k),
reverse=True,
)
remainder = total_samples_requested - sum(requested_target_by_k.values())
for k in fractional_order[:remainder]:
requested_target_by_k[k] += 1
# Cap by per-k capacity
allocated_by_k = {
k: min(requested_target_by_k[k], capacity_by_k[k])
for k in range(1, active_max_unique_k + 1)
}
# Redistribute shortfall to k with remaining capacity
shortfall = total_samples_requested - sum(allocated_by_k.values())
while shortfall > 0:
eligible = [k for k in range(1, active_max_unique_k + 1) if allocated_by_k[k] < capacity_by_k[k]]
if not eligible:
break
weight_sum = sum(normalized_proportion_by_k[k] for k in eligible)
if weight_sum <= 0:
per_weight = {k: 1.0 / len(eligible) for k in eligible}
else:
per_weight = {k: normalized_proportion_by_k[k] / weight_sum for k in eligible}
raw_extra = {k: shortfall * per_weight[k] for k in eligible}
base_extra = {}
used = 0
for k in eligible:
room = capacity_by_k[k] - allocated_by_k[k]
add = min(room, int(raw_extra[k]))
base_extra[k] = add
used += add
for k, add in base_extra.items():
allocated_by_k[k] += add
shortfall -= used
if shortfall <= 0:
break
eligible_after_base = [k for k in eligible if allocated_by_k[k] < capacity_by_k[k]]
if not eligible_after_base:
break
fractional_sorted = sorted(
eligible_after_base,
key=lambda k: (raw_extra[k] - int(raw_extra[k]), per_weight[k], -k),
reverse=True,
)
if used == 0:
fractional_sorted = sorted(
eligible_after_base,
key=lambda k: (capacity_by_k[k] - allocated_by_k[k], per_weight[k], -k),
reverse=True,
)
progress = False
for k in fractional_sorted:
if shortfall <= 0:
break
if allocated_by_k[k] >= capacity_by_k[k]:
continue
allocated_by_k[k] += 1
shortfall -= 1
progress = True
if not progress:
break
# ------------------------------------------------------------------
# Build final sample list exactly per-k allocations
# ------------------------------------------------------------------
sample_pool_enumeration_threshold = 1_000_000
samples = []
count_breakdown_by_k = {k: 0 for k in range(1, active_max_unique_k + 1)}
def generate_all_candidates_for_k(k):
for mat_type in material_types:
for vf in vol_fractions:
for quarter_layer_count in quarter_layer_counts:
if quarter_layer_count < k:
continue
for quarter_angles in combinations_with_replacement(candidate_angles, quarter_layer_count):
if count_distinct_quarter_angles(quarter_angles) != k:
continue
yield make_sample(
material_type=mat_type,
vol_fraction=vf,
quarter_angles=quarter_angles,
)
for k in range(1, active_max_unique_k + 1):
target_k = allocated_by_k[k]
if target_k <= 0:
continue
capacity_k = capacity_by_k[k]
exhaustive_for_k = target_k >= capacity_k
if exhaustive_for_k or capacity_k <= sample_pool_enumeration_threshold:
candidate_samples = list(generate_all_candidates_for_k(k))
if exhaustive_for_k:
selected_samples = candidate_samples
else:
selected_idx = rng.choice(len(candidate_samples), size=target_k, replace=False)
selected_samples = [candidate_samples[i] for i in np.sort(selected_idx)]
samples.extend(selected_samples)
count_breakdown_by_k[k] += len(selected_samples)
continue
# Large-space random unique sampling for this k
seen_keys = set()
material_indices = np.arange(len(material_types))
vf_indices = np.arange(len(vol_fractions))
angle_indices = np.arange(len(candidate_angles))
q_choices = np.array(sorted(capacity_by_k_and_q[k].keys()), dtype=int)
q_weights = np.array([capacity_by_k_and_q[k][q] for q in q_choices], dtype=float)
q_weights = q_weights / q_weights.sum()
max_attempts = max(50_000, target_k * 300)
attempts = 0
while len(seen_keys) < target_k and attempts < max_attempts:
mat_idx = int(rng.choice(material_indices))
vf_idx = int(rng.choice(vf_indices))
quarter_layer_count = int(rng.choice(q_choices, p=q_weights))
family_idx_tuple = tuple(sorted(rng.choice(angle_indices, size=k, replace=False)))
family_angles = tuple(candidate_angles[i] for i in family_idx_tuple)
family_counts = sample_positive_composition(quarter_layer_count, k, rng)
quarter_angles = []
for angle, count in zip(family_angles, family_counts):
quarter_angles.extend([angle] * count)
sample = make_sample(
material_type=material_types[mat_idx],
vol_fraction=vol_fractions[vf_idx],
quarter_angles=quarter_angles,
)
key = (
sample['material_type'],
format_vol_fraction(sample['vol_fraction']),
tuple(sample['quarter_angles']),
)
if key not in seen_keys:
seen_keys.add(key)
samples.append(sample)
count_breakdown_by_k[k] += 1
attempts += 1
if count_breakdown_by_k[k] < target_k:
raise RuntimeError(
f"Could not reach allocated target for k={k}. "
f"Requested={target_k:,}, generated={count_breakdown_by_k[k]:,}, "
f"capacity={capacity_k:,}. Try lowering total_samples or adjusting proportions."
)
samples.sort(
key=lambda s: (
s['material_type'],
format_vol_fraction(s['vol_fraction']),
s['unique_angle_count'],
s['quarter_layer_count'],
tuple(s['quarter_angles']),
tuple(s['full_angles'])
)
)
total_samples = len(samples)
allocation_report = {
'requested_total_samples': total_samples_requested,
'total_capacity': total_capacity,
'global_shortfall': max(0, total_samples_requested - total_capacity),
'ignored_k_values': ignored_k_values,
'active_max_unique_k': active_max_unique_k,
'quarter_layer_counts': quarter_layer_counts,
'normalized_proportion_by_k': normalized_proportion_by_k,
'requested_target_by_k': requested_target_by_k,
'capacity_by_k': capacity_by_k,
'allocated_by_k': allocated_by_k,
'generated_by_k': count_breakdown_by_k.copy(),
}
return samples, total_samples, count_breakdown_by_k, allocation_report
def format_vol_fraction(vf):
"""
Format volume fraction as a 4-decimal string for file naming.
Examples:
0.1 -> "0.1000"
"0.1" -> "0.1000"
"0.1000" -> "0.1000"
"""
try:
return f"{float(vf):.4f}"
except (TypeError, ValueError):
return str(vf)
def save_sample_plot(plot_data, meta, save_path):
"""
Save one 7-panel plot for a single generated sample.
Expected plot_data structure:
plot_data = {
"11": {
"x": np.ndarray,
"stress": np.ndarray,
"lateral": np.ndarray,
"eps33": np.ndarray,
},
"22": {
"x": np.ndarray,
"stress": np.ndarray,
"lateral": np.ndarray,
"eps33": np.ndarray,
},
"12": {
"x": np.ndarray,
"stress": np.ndarray,
},
}
Expected meta structure:
meta = {
"material_type": str,
"vol_fraction": str,
"quarter_angles": list,
}
"""
save_path = Path(save_path)
save_path.parent.mkdir(parents=True, exist_ok=True)
fig, axes = plt.subplots(3, 3, figsize=(16, 16))
ax = axes
# ---------------- Row 1: stress ----------------
if "11" in plot_data:
ax[0, 0].plot(plot_data["11"]["x"], plot_data["11"]["stress"], marker="o")
ax[0, 0].set_title("Mode 11 - Stress")
ax[0, 0].set_xlabel("Strain")
ax[0, 0].set_ylabel("Stress (MPa)")
ax[0, 0].grid(True, alpha=0.3)
if "22" in plot_data:
ax[0, 1].plot(plot_data["22"]["x"], plot_data["22"]["stress"], marker="o")
ax[0, 1].set_title("Mode 22 - Stress")
ax[0, 1].set_xlabel("Strain")
ax[0, 1].set_ylabel("Stress (MPa)")
ax[0, 1].grid(True, alpha=0.3)
if "12" in plot_data:
ax[0, 2].plot(plot_data["12"]["x"], plot_data["12"]["stress"], marker="o")
ax[0, 2].set_title("Mode 12 - Stress")
ax[0, 2].set_xlabel("Strain")
ax[0, 2].set_ylabel("Stress (MPa)")
ax[0, 2].grid(True, alpha=0.3)
# ---------------- Row 2: lateral ----------------
if "11" in plot_data:
ax[1, 0].plot(plot_data["11"]["x"], plot_data["11"]["lateral"], marker="o")
ax[1, 0].set_title("Mode 11 - Lateral")
ax[1, 0].set_xlabel("Strain")
ax[1, 0].set_ylabel("Lateral Strain")
ax[1, 0].grid(True, alpha=0.3)
if "22" in plot_data:
ax[1, 1].plot(plot_data["22"]["x"], plot_data["22"]["lateral"], marker="o")
ax[1, 1].set_title("Mode 22 - Lateral")
ax[1, 1].set_xlabel("Strain")
ax[1, 1].set_ylabel("Lateral Strain")
ax[1, 1].grid(True, alpha=0.3)
ax[1, 2].axis("off")
# ---------------- Row 3: eps_33 ----------------
if "11" in plot_data:
ax[2, 0].plot(plot_data["11"]["x"], plot_data["11"]["eps33"], marker="o")
ax[2, 0].set_title("Mode 11 - eps_33")
ax[2, 0].set_xlabel("Strain")
ax[2, 0].set_ylabel("eps_33")
ax[2, 0].grid(True, alpha=0.3)
if "22" in plot_data:
ax[2, 1].plot(plot_data["22"]["x"], plot_data["22"]["eps33"], marker="o")
ax[2, 1].set_title("Mode 22 - eps_33")
ax[2, 1].set_xlabel("Strain")
ax[2, 1].set_ylabel("eps_33")
ax[2, 1].grid(True, alpha=0.3)
ax[2, 2].axis("off")
material_type = meta.get("material_type", "Unknown")
vol_fraction = meta.get("vol_fraction", "Unknown")
quarter_angles = meta.get("quarter_angles", [])
fig.suptitle(
f"Material: {material_type}, VF={vol_fraction}, Q-angles={quarter_angles}",
fontsize=12
)
fig.tight_layout(rect=[0, 0, 1, 0.96])
fig.savefig(save_path, dpi=200, bbox_inches="tight")
plt.close(fig)
def worker_function(sample, curve_dir, out_dir, config):
"""
Worker function for multiprocessing.
Generates one output file for a given parameter combination.
"""
try:
mat_type = sample['material_type']
vf = sample['vol_fraction']
quarter_angles = sample['quarter_angles']
half_angles = sample['half_angles']
center_angle = sample['center_angle']
full_angles = sample['full_angles']
vf_str = format_vol_fraction(vf)
prefix = f"{mat_type}_{vf_str}"
# Load material (this reads from files)
curve_dir_path = Path(curve_dir)
# Temporarily modify CURVE_DIR in lam module
original_curve_dir = lam.CURVE_DIR
lam.CURVE_DIR = curve_dir_path
try:
metadata_result = read_dataset_metadata(prefix)
if not isinstance(metadata_result, tuple) or len(metadata_result) != 3:
lam.CURVE_DIR = original_curve_dir
return False, (
f"read_dataset_metadata returned unexpected format for {prefix}: "
f"{type(metadata_result)}, length: "
f"{len(metadata_result) if hasattr(metadata_result, '__len__') else 'N/A'}"
)
vf_meta, centers_meta, n_fibers = metadata_result
mat = load_ud_material_from_files(prefix)
except Exception as e:
lam.CURVE_DIR = original_curve_dir
import traceback
return False, (
f"Failed to load material for {prefix}: "
f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
)
finally:
lam.CURVE_DIR = original_curve_dir
# Use quarter angles for the file name, but keep the full stack label for metadata.
quarter_label_file = "_".join(
str(int(round(float(a))))
for a in quarter_angles
)
stack_label_file = quarter_label_file
stack_label_human = stack_label_from_upper(full_angles)
combined_blocks = {}
plot_data = {}
save_sample_plots = config.get('save_sample_plots', False)
plot_out_dir_path = None
if save_sample_plots:
plot_output_directory = config.get('plot_output_directory')
if plot_output_directory:
plot_out_dir_path = Path(plot_output_directory)
else:
plot_out_dir_path = Path(str(out_dir) + "_plots")
plot_out_dir_path.mkdir(parents=True, exist_ok=True)
# Run simulations for all three modes
mode_errors = []
for mode in ("11", "22", "12"):
try:
result = run_simulation_with_mat(prefix, full_angles, mode, mat)
# Verify we got the expected number of return values
if len(result) != 8:
raise ValueError(f"Expected 8 return values, got {len(result)}")
ex, sx, ey, gxy, ezz, g23, g13, e11 = result
except Exception as e:
# Skip this mode if it fails
import traceback
tb_str = traceback.format_exc()
error_detail = f"{type(e).__name__}: {str(e)}"
if "not enough values to unpack" in str(e):
error_detail += f"\nTraceback:\n{tb_str}"
mode_errors.append(f"Mode {mode}: {error_detail}")
continue
# Process output (configurable number of points)
num_output_points = config.get('num_output_points', 10)
x_out = np.linspace(ex[0], ex[-1], num_output_points)
sx_out_MPa = np.interp(x_out, ex, sx / 1e6)
if mode == "11":
strain_label = "eps_11"
stress_label = "sig_11"
lateral_label = "eps_22"
lateral_series = ey
e33_label = "eps_33"
e33_series = ezz
elif mode == "22":
strain_label = "eps_22"
stress_label = "sig_22"
lateral_label = "eps_11"
lateral_series = e11
e33_label = "eps_33"
e33_series = ezz
elif mode == "12":
strain_label = "eps_12"
stress_label = "sig_12"
lateral_label = None
lateral_series = None
e33_label = None
e33_series = None
else:
raise ValueError(f"Unknown mode {mode}")
if lateral_series is not None:
lateral_out = np.interp(x_out, ex, lateral_series)
else:
lateral_out = None
if e33_series is not None:
e33_out = np.interp(x_out, ex, e33_series)
else:
e33_out = None
if lateral_label is None:
header_line = f"{strain_label:<8} {stress_label:<8}"
else:
header_line = (
f"{strain_label:<8} {stress_label:<8} "
f"{lateral_label:<8} {e33_label:<8}"
)
rows = []
if lateral_label is None:
for eps_val, sig_val in zip(x_out, sx_out_MPa):
line = f"{eps_val:8.6f} {sig_val:8.3f}"
rows.append(line)
else:
for eps_val, sig_val, lat_val, e33_val in zip(
x_out, sx_out_MPa, lateral_out, e33_out
):
line = (
f"{eps_val:8.6f} {sig_val:8.3f} "
f"{lat_val:8.6f} {e33_val:8.6f}"
)
rows.append(line)
combined_blocks[mode] = (header_line, rows)
if mode in ("11", "22"):
plot_data[mode] = {
"x": np.array(x_out, copy=True),
"stress": np.array(sx_out_MPa, copy=True),
"lateral": np.array(lateral_out, copy=True),
"eps33": np.array(e33_out, copy=True),
}
elif mode == "12":
plot_data[mode] = {
"x": np.array(x_out, copy=True),
"stress": np.array(sx_out_MPa, copy=True),
}
# Write output file
out_dir_path = Path(out_dir)
out_dir_path.mkdir(parents=True, exist_ok=True)
combined_file = out_dir_path / (
f"{mat_type}_{vf_str}_{stack_label_file}.txt"
)
# Only write file if at least one mode succeeded
if not combined_blocks:
error_msg = "All modes failed for this sample"
if mode_errors:
error_msg += f" - Errors: {'; '.join(mode_errors)}"
return False, error_msg
with open(combined_file, "w") as fc:
# Write 11, then 22, then 12 in order
for m in ("11", "22", "12"):
if m in combined_blocks:
header_line, rows = combined_blocks[m]
fc.write(header_line + "\n")
for line in rows:
fc.write(line + "\n")
fc.write("\n")
# One common metadata block at the end
fc.write(f"volume fraction= {vf_meta:.6f}\n")
fc.write(f"material type= {mat_type}\n")
fc.write("loading modes= 11, 22, 12\n")
fc.write(f"stacking sequence= {stack_label_human}\n")
fc.write(f"number of fibers= {n_fibers}\n")
fc.write(f"fiber_centers_YZ={centers_meta}\n")
if save_sample_plots and plot_data:
plot_meta = {
"material_type": mat_type,
"vol_fraction": vf_str,
"quarter_angles": quarter_angles,
}
plot_file = plot_out_dir_path / (
f"{mat_type}_{vf_str}_{stack_label_file}.png"
)
save_sample_plot(plot_data, plot_meta, plot_file)
return True, None
except Exception as e:
return False, str(e)
def run_simulation_with_mat(prefix, full_angles, mode, mat):
"""
Wrapper to run simulation with material object.
This works around the issue that run_uniaxial_test_from_files_5d uses mat
but doesn't receive it as parameter.
"""
import numpy as np
tply = 0.05
plies = [Ply(angle_deg, tply, mat) for angle_deg in full_angles]
laminate = Laminate(plies)
if mode == "11":
main_index = 0
eps_max = 0.10
elif mode == "22":
main_index = 1
eps_max = 0.10
elif mode == "12":
main_index = 5
eps_max = 0.20
else:
raise ValueError("mode must be '11', '22' or '12'")
main_steps = np.linspace(0.0, eps_max, 1500)
ex_hist, sx_hist = [], []
ey_hist, gxy_hist = [], []
ezz_hist, g23_hist, g13_hist = [], [], []
e11_hist = []
ex_prev = 0.0
ey_prev = 0.0
gxy_prev = 0.0
ezz_prev = 0.0
g23_prev = 0.0
g13_prev = 0.0
s1_prev = 0.0
for i in range(1, len(main_steps)):
main_target = main_steps[i]
if main_index == 0:
main_prev = ex_prev
elif main_index == 1:
main_prev = ey_prev
else:
main_prev = gxy_prev
dmain = main_target - main_prev
Ceff_prev = laminate.effective_C_from_previous_strains(
ex_prev, ey_prev, ezz_prev, g23_prev, g13_prev, gxy_prev
)
cond = np.linalg.cond(Ceff_prev)
if not np.isfinite(cond) or cond > COND_MAX:
raise np.linalg.LinAlgError(
f"Effective C is ill-conditioned (cond={cond:.3e}) at step {i}"
)
e_j = np.zeros(6)
e_j[main_index] = 1.0
try:
S_col = np.linalg.solve(Ceff_prev, e_j)
except np.linalg.LinAlgError as err:
raise np.linalg.LinAlgError(
f"Failed to solve for compliance column at step {i}: {err}"
)
Sjj = S_col[main_index]
if abs(Sjj) < 1e-20:
raise ZeroDivisionError(
f"Sjj is zero or too small at step {i} (Sjj={Sjj:.3e})."
)
ds1 = dmain / Sjj
de_vec = S_col * ds1
de_vec[main_index] = dmain
de1, de2, de3, de4, de5, de6 = de_vec
de4 = 0.0
de5 = 0.0
s1 = s1_prev + ds1
ex = ex_prev + de1
ey = ey_prev + de2
ezz = ezz_prev + de3
g23 = g23_prev + de4
g13 = g13_prev + de5
gxy = gxy_prev + de6
laminate.update_fiber_angles_incremental(de1, de2, de6)
ex_prev, ey_prev, ezz_prev = ex, ey, ezz
g23_prev, g13_prev, gxy_prev = g23, g13, gxy
s1_prev = s1
if main_index == 0:
main_strain = ex
elif main_index == 1:
main_strain = ey
else:
main_strain = 0.5 * gxy
ex_hist.append(main_strain)
sx_hist.append(s1)
ey_hist.append(ey)
gxy_hist.append(gxy)
ezz_hist.append(ezz)
g23_hist.append(g23)
g13_hist.append(g13)
e11_hist.append(ex)
# Ensure all arrays are created properly
result = (
np.array(ex_hist),
np.array(sx_hist),
np.array(ey_hist),
np.array(gxy_hist),
np.array(ezz_hist),
np.array(g23_hist),
np.array(g13_hist),
np.array(e11_hist)
)
# Verify we're returning 8 values
if len(result) != 8:
raise ValueError(f"Internal error: expected 8 return values, got {len(result)}")
return result
def main():
if len(sys.argv) < 2:
print("Usage: python generate_data_mp.py <config.yaml>")
sys.exit(1)
config_path = Path(sys.argv[1])
if not config_path.exists():
print(f"Error: Config file not found: {config_path}")
sys.exit(1)
# Load configuration
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
# Get paths
curve_dir = Path(config.get('input_directory', 'shahriar_modified_2025_12/RVE_Datasets'))
out_dir = Path(config.get('output_directory', 'shahriar_modified_2025_12/Output_directory'))
num_processes = config.get('num_processes', mp.cpu_count())
# Generate parameter space
print("Generating parameter space...")
samples, total_samples, count_breakdown_by_k, allocation_report = generate_parameter_space(config)
# New workflow summary
sampling_mode = "Proportion-Based Unique-Angle Allocation"
vf_config = config.get('vol_fractions', {})
angles_config = config.get('candidate_angles', {})
proportion_cfg = config.get('unique_angle_k_proportions', {})
quarter_layer_counts = allocation_report.get(
'quarter_layer_counts',
config.get('quarter_layer_counts', [])
)
# Parse candidate angle count for display
if isinstance(angles_config, dict) and 'values' in angles_config:
display_candidate_angles = parse_discrete_value(angles_config['values'])
else:
display_candidate_angles = parse_discrete_value(angles_config)
# Display values should come from the actual quarter-angle workflow
max_full_layers = 4 * max(quarter_layer_counts) if quarter_layer_counts else 0
requested_k_values = sorted(int(k) for k in proportion_cfg.keys()) if proportion_cfg else [1]
max_unique_k_requested = max(requested_k_values) if requested_k_values else 1
# These must come from the actual generated allocation report,
# not be recomputed using obsolete full_layer_counts logic
active_max_unique_k = allocation_report['active_max_unique_k']
ignored_k_values = allocation_report['ignored_k_values']
print("\n" + "="*70)
print("GENERATION SUMMARY")
print("="*70)
print(f"Sampling mode: {sampling_mode}")
print(f"Requested total samples: {allocation_report['requested_total_samples']:,}")
print(f"Total number of samples (computed before generation): {total_samples:,}")
print(f"Global feasible capacity: {allocation_report['total_capacity']:,}")
if allocation_report['global_shortfall'] > 0:
print(f"Unfillable shortfall (capacity limit): {allocation_report['global_shortfall']:,}")
print(f"Number of processes: {num_processes}")
print(f"Number of output points per curve: {config.get('num_output_points', 10)}")
print(f"Input directory: {curve_dir}")
print(f"Output directory: {out_dir}")
print(f"Maximum full layers: {max_full_layers}")
print(f"Requested maximum unique-angle family size: {max_unique_k_requested}")
print(f"Feasible maximum unique-angle family size: {active_max_unique_k}")
print(f"Candidate angle pool size: {len(display_candidate_angles)}")
if ignored_k_values:
print(f"Ignored family sizes (not feasible with current candidate angle pool): {ignored_k_values}")
print("Unique-angle proportions and allocation details:")
for k in range(1, active_max_unique_k + 1):
raw_val = proportion_cfg[str(k)] if str(k) in proportion_cfg else proportion_cfg[k]
norm_prop = allocation_report['normalized_proportion_by_k'][k]
requested_target = allocation_report['requested_target_by_k'][k]
capacity = allocation_report['capacity_by_k'][k]
allocated = allocation_report['allocated_by_k'][k]
generated = count_breakdown_by_k.get(k, 0)
print(
f" k={k}: weight={float(raw_val):.6g}, normalized={norm_prop:.2%}, "
f"requested={requested_target:,}, capacity={capacity:,}, "
f"allocated={allocated:,}, generated={generated:,}"
)
print("Sample count breakdown by active family size:")
for k in range(1, active_max_unique_k + 1):
print(f" k={k}: {count_breakdown_by_k.get(k, 0):,}")
print(f"Total samples from breakdown: {sum(count_breakdown_by_k.values()):,}")
print(f"Total allocated by plan: {sum(allocation_report['allocated_by_k'].values()):,}")
print(f"Total number of samples: {total_samples:,}")
print("="*70)
# Ask for confirmation (skip if running in non-interactive mode)
if sys.stdin.isatty():
response = input("\nProceed with generation? (yes/no): ").strip().lower()
if response not in ['yes', 'y']:
print("Generation cancelled.")
sys.exit(0)
else:
print("\nNon-interactive mode: proceeding with generation...")
# Create output directory if it doesn't exist
out_dir.mkdir(parents=True, exist_ok=True)
# Copy config file to output directory for reference
config_copy_path = out_dir / "generation_config.yaml"
shutil.copy2(config_path, config_copy_path)
print(f"Config file copied to: {config_copy_path}")
# Record start time
start_time = datetime.now()
start_timestamp = start_time.isoformat()
# Create worker function with fixed arguments
worker = partial(worker_function,
curve_dir=str(curve_dir),
out_dir=str(out_dir),
config=config)
# Run multiprocessing
print(f"\nStarting generation with {num_processes} processes...\n")
completed = 0
failed = 0
errors_list = []
with mp.Pool(processes=num_processes) as pool:
# Use tqdm for progress bar
results = pool.imap(worker, samples)
with tqdm(total=total_samples, desc="Generating samples", unit="sample") as pbar:
for i, (success, error) in enumerate(results, 1):
if success:
completed += 1
else:
failed += 1
if error:
errors_list.append(f"Sample {i}: {error}")
# Update progress bar with current stats
pbar.set_postfix({
'Completed': completed,
'Failed': failed,
'Success %': f"{100*completed/i:.1f}%"
})
pbar.update(1)
# Record end time
end_time = datetime.now()
end_timestamp = end_time.isoformat()
duration = end_time - start_time
duration_seconds = duration.total_seconds()
duration_hours = duration_seconds / 3600
duration_minutes = (duration_seconds % 3600) / 60
# Format duration string
if duration_seconds < 60:
duration_str = f"{duration_seconds:.2f} seconds"
elif duration_seconds < 3600:
duration_str = f"{int(duration_minutes)} minutes {duration_seconds % 60:.2f} seconds"
else:
duration_str = f"{int(duration_hours)} hours {int(duration_minutes)} minutes {duration_seconds % 60:.2f} seconds"
# Calculate statistics
success_rate = 100 * completed / total_samples if total_samples > 0 else 0
avg_time_per_sample = duration_seconds / total_samples if total_samples > 0 else 0
print("\n" + "="*70)
print("GENERATION COMPLETE")
print("="*70)
print(f"Total samples: {total_samples}")
print(f"Successfully completed: {completed}")
print(f"Failed: {failed}")
print(f"Success rate: {success_rate:.2f}%")
print(f"Total duration: {duration_str}")
print(f"Average time per sample: {avg_time_per_sample:.2f} seconds")
print(f"Output directory: {out_dir}")
if errors_list:
print(f"\nFirst {min(10, len(errors_list))} errors:")
for err in errors_list[:10]:
print(f" - {err}")
if len(errors_list) > 10:
print(f" ... and {len(errors_list) - 10} more errors")
print("="*70)
# Create summary file
summary = {
'generation_info': {
'start_time': start_timestamp,
'end_time': end_timestamp,
'duration_seconds': duration_seconds,
'duration_formatted': duration_str,
'sampling_mode': sampling_mode,
'requested_max_unique_k': max_unique_k_requested,
'active_max_unique_k': allocation_report['active_max_unique_k'],
'ignored_k_values': allocation_report['ignored_k_values'],
},
'statistics': {
'total_samples': total_samples,
'successfully_completed': completed,
'failed': failed,
'success_rate_percent': round(success_rate, 2),
'average_time_per_sample_seconds': round(avg_time_per_sample, 2),
},
'configuration': {
'num_processes': num_processes,
'num_output_points': config.get('num_output_points', 10),
'input_directory': str(curve_dir),
'output_directory': str(out_dir),
'sampling_workflow': config.get('sampling_workflow'),
'material_types': config.get('material_types', []),
'vol_fractions': config.get('vol_fractions', {}),
'candidate_angles': config.get('candidate_angles', {}),
'quarter_layer_counts': allocation_report.get(
'quarter_layer_counts',
config.get('quarter_layer_counts', [])
),
'max_full_layers': max_full_layers,
'total_samples': config.get('total_samples'),
'unique_angle_k_proportions': config.get('unique_angle_k_proportions', {}),
'random_seed': config.get('random_seed'),
},
'allocation_report': allocation_report,
'errors': {
'total_errors': len(errors_list),
'error_samples': errors_list[:50] if len(errors_list) <= 50 else errors_list[:50] + [f"... and {len(errors_list) - 50} more errors"]
}
}
# Save summary as both YAML and JSON for flexibility
summary_yaml_path = out_dir / "generation_summary.yaml"
summary_json_path = out_dir / "generation_summary.json"
with open(summary_yaml_path, 'w') as f:
yaml.dump(summary, f, default_flow_style=False, sort_keys=False)
with open(summary_json_path, 'w') as f:
json.dump(summary, f, indent=2, sort_keys=False)
print(f"\nSummary files saved:")
print(f" - {summary_yaml_path}")
print(f" - {summary_json_path}")
print(f" - {config_copy_path}")
if __name__ == "__main__":
main()