| |
| """ |
| 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 |
| |
| |
| lam_path = Path(__file__).parent |
| sys.path.insert(0, str(lam_path)) |
|
|
|
|
| |
| 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) |
|
|
| |
| 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']) |
| |
| result = [] |
| current = start |
| while current <= end + 1e-9: |
| 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'." |
| ) |
|
|
| |
| |
| |
| 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.") |
|
|
| |
| |
| |
| 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." |
| ) |
|
|
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| 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.") |
|
|
| |
| |
| |
| random_seed = config.get('random_seed') |
| rng = np.random.default_rng(random_seed) |
|
|
| |
| |
| |
| 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) |
| } |
|
|
| |
| 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 |
|
|
| |
| allocated_by_k = { |
| k: min(requested_target_by_k[k], capacity_by_k[k]) |
| for k in range(1, active_max_unique_k + 1) |
| } |
|
|
| |
| 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 |
|
|
| |
| |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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") |
|
|
| |
| 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}" |
|
|
| |
| curve_dir_path = Path(curve_dir) |
|
|
| |
| 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 |
|
|
| |
| |
| 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) |
|
|
| |
| mode_errors = [] |
| for mode in ("11", "22", "12"): |
| try: |
| result = run_simulation_with_mat(prefix, full_angles, mode, mat) |
|
|
| |
| 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: |
| |
| 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 |
|
|
| |
| 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), |
| } |
| |
| 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" |
| ) |
|
|
| |
| 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: |
| |
| 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") |
|
|
| |
| 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) |
| |
| |
| 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) |
| ) |
| |
| |
| 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) |
| |
| |
| with open(config_path, 'r') as f: |
| config = yaml.safe_load(f) |
| |
| |
| 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()) |
| |
| |
| print("Generating parameter space...") |
| samples, total_samples, count_breakdown_by_k, allocation_report = generate_parameter_space(config) |
| |
| |
| 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', []) |
| ) |
| |
| |
| 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) |
| |
| |
| 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 |
| |
| |
| |
| 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) |
| |
| |
| 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...") |
| |
| |
| out_dir.mkdir(parents=True, exist_ok=True) |
| |
| |
| config_copy_path = out_dir / "generation_config.yaml" |
| shutil.copy2(config_path, config_copy_path) |
| print(f"Config file copied to: {config_copy_path}") |
| |
| |
| start_time = datetime.now() |
| start_timestamp = start_time.isoformat() |
| |
| |
| worker = partial(worker_function, |
| curve_dir=str(curve_dir), |
| out_dir=str(out_dir), |
| config=config) |
| |
| |
| print(f"\nStarting generation with {num_processes} processes...\n") |
| |
| completed = 0 |
| failed = 0 |
| errors_list = [] |
| |
| with mp.Pool(processes=num_processes) as pool: |
| |
| 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}") |
| |
| |
| pbar.set_postfix({ |
| 'Completed': completed, |
| 'Failed': failed, |
| 'Success %': f"{100*completed/i:.1f}%" |
| }) |
| pbar.update(1) |
| |
| |
| 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 |
| |
| |
| 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" |
| |
| |
| 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) |
| |
| |
| 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"] |
| } |
| } |
| |
| |
| 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() |
|
|
|
|