Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions dev/vfm/call_vfm.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@
SpecimenGeometry,
)
from pyvale.vfm.hardening import LinearHardening
from pyvale.vfm.identification import Identification, IdentificationPhase
from pyvale.vfm.identification import run_identification
from pyvale.vfm.identificationconfig import (
IdentificationConfig,
IdentificationPhase,
)
from pyvale.vfm.metricsbvf import SensitivityBasedVirtualFieldsMetric
from pyvale.vfm.objectivefuncvector import VectorFirstResultPassthrough
from pyvale.vfm.optimiserleastsquares import LeastSquares
from pyvale.vfm.spatialparamhomogeneous import (
HomogeneousSpatialParameterisation,
)
from pyvale.vfm.spatialparamknown import KnownSpatialParameterisation
from pyvale.vfm.vfm import run_identification

inputs_path = Path(__file__).resolve().parent / "inputs"

Expand Down Expand Up @@ -100,15 +103,15 @@ def main():
)
]

identification = Identification(
identification_config = IdentificationConfig(
IsotropicVonMisesElastoplasticity(
LinearHardening()
),
parameters,
phases
)

vfm_result = run_identification(experiment_data, identification)
vfm_result = run_identification(experiment_data, identification_config)
print(vfm_result)


Expand Down
4 changes: 2 additions & 2 deletions src/pyvale/vfm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
# Copyright (C) 2025 The Computer Aided Validation Team
#===============================================================================

from .vfm import *
from .identification import *
from .identificationconfig import *

from .experimentdata import *
from .identification import *

from .constlaw import *
from .constlaws import *
Expand Down
54 changes: 54 additions & 0 deletions src/pyvale/vfm/constlaw.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,51 @@


class EIdentificationType(enum.Enum):
"""
Identifies whether a constitutive law's parameters should be
identified with linear on nonlinear identification
"""

Linear = enum.auto()
"""Identify parameters with linear identification"""
Nonlinear = enum.auto()
"""Identify parameters with nonlinear identification"""


class IConstitutiveLaw(ABC):
"""
Interface (abstract base class) for a constitutive law.

Provides the material model that relates strain to stress. Concrete
implementations define the specific constitutive equations and report
whether identification is linear or nonlinear
"""

@abstractmethod
def get_identification_type(self) -> EIdentificationType:
"""
Indicate whether this law is linear or nonlinear in its parameters.

Returns
-------
EIdentificationType
``Linear`` or ``Nonlinear``
"""
pass

@abstractmethod
def get_required_parameters(self) -> list[str]:
"""
Return the list of required constitutive parameters for this law.

Concrete implementations should combine their own parameter names
with those from any nested hardening law

Returns
-------
list[str]
All parameter name strings this law requires
"""
pass

@abstractmethod
Expand All @@ -21,4 +59,20 @@ def calculate_stress(
strain: npt.NDArray[np.float64],
constitutive_parameter_maps: dict[str, npt.NDArray[np.float64]],
) -> npt.NDArray[np.float64]:
"""
Compute stress from the current strain and parameter maps.

Parameters
----------
strain : npt.NDArray[np.float64]
Full-field strain history, shape ``(timesteps, components, y, x)``
constitutive_parameter_maps : dict[str, npt.NDArray[np.float64]]
Dictionary of current parameter values as 2D maps,
keyed by parameter name

Returns
-------
npt.NDArray[np.float64]
Stress field with the same shape as ``strain``
"""
pass
5 changes: 5 additions & 0 deletions src/pyvale/vfm/constlaws.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ def __init__(
def get_identification_type(self) -> EIdentificationType:
return EIdentificationType.Nonlinear

def get_required_parameters(self) -> list[str]:
params = [self.elastic_modulus_label, self.poissons_ratio_label]
params.extend(self.hardening_function.get_required_parameters())
return params

def calculate_stress(
self,
strain: npt.NDArray[np.float64],
Expand Down
58 changes: 51 additions & 7 deletions src/pyvale/vfm/constparam.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,75 @@

@dataclass(slots=True)
class ConstitutiveParameter:
value: npt.NDArray[np.float64]
"""
A single constitutive parameter with a spatially varying map of values and
associated value bounds.

The ``value`` may be supplied as a scalar (int or float) together with a
``map_size`` to create a homogeneous 2D field, or as a full 2D array
directly
"""

map: npt.NDArray[np.float64]
"""Parameter map, shape ``(y, x)``"""

lower_bound: float
"""Lower bound for the parameter value"""

upper_bound: float
"""Upper bound for the parameter value"""

# TODO: enforce bounds on input value
# map_size must be in form (y, x)
def __init__(
self,
value: int | float | npt.NDArray[np.float64],
lower_bound: float,
upper_bound: float,
map_size: npt.NDArray[np.float64] | None = None
map_size: npt.NDArray[np.float64] | None = None,
) -> None:
"""
Parameters
----------
value : int | float | npt.NDArray[np.float64]
Parameter value. If scalar, ``map_size`` must also be provided
and the value is broadcast to create a homogeneous 2D field
lower_bound : float
Lower bound for the parameter
upper_bound : float
Upper bound for the parameter
map_size : npt.NDArray[np.float64] | None, optional
Shape ``(y, x)`` of the spatial parameterisation when ``value``
is a scalar. Ignored when ``value`` is already an array

Raises
------
ValueError
If ``lower_bound >= upper_bound``, or if any value in the
parameter map lies outside ``[lower_bound, upper_bound]``, or
if ``value`` is a scalar and ``map_size`` is ``None``
"""
if lower_bound >= upper_bound:
raise ValueError(
f"lower_bound ({lower_bound}) must be less than "
f"upper_bound ({upper_bound})"
)

if isinstance(value, (int, float)):
if map_size is None:
raise ValueError(
"map_size must be defined if "
"parameter value is int or float"
)

self.value = np.full((map_size[0], map_size[1]), value)
self.map = np.full((map_size[0], map_size[1]), value)

else:
self.value = value
self.map = value

if np.any((self.map < lower_bound) | (self.map > upper_bound)):
raise ValueError(
f"parameter values must be within provided bounds "
f"[{lower_bound}, {upper_bound}]"
)

self.lower_bound = lower_bound
self.upper_bound = upper_bound

8 changes: 8 additions & 0 deletions src/pyvale/vfm/dof.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@

@dataclass(slots=True)
class DegreeOfFreedom:
"""
A single scalar degree of freedom with bounds.

Used by the optimiser to explore the design space. Values are typically
normalised to ``[0, 1]`` during optimisation and denormalised to the
physical range ``[lower_bound, upper_bound]`` for evaluation
"""

value: float
lower_bound: float
upper_bound: float
107 changes: 105 additions & 2 deletions src/pyvale/vfm/experimentdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,95 @@

@dataclass(slots=True)
class SpecimenGeometry:
"""
Physical geometry of the test specimen.

Stores the spatial coordinates, region-of-interest mask, thickness, and
per-point physical area of the DIC grid
"""

x: npt.NDArray[np.float64]
"""
x-coordinates at each grid point, shape ``(y, x)`` (mm).

Always positive, increasing left to right (column index)
"""

y: npt.NDArray[np.float64]
# TODO: not sure exactly what this should be
region_of_interest: npt.NDArray[np.uint32]
"""
y-coordinates at each grid point, shape ``(y, x)`` (mm).

Always positive, increasing top to bottom (row index)
"""

region_of_interest: npt.NDArray[np.bool_]
"""Boolean mask of valid analysis points, shape ``(y, x)``"""

thickness: float
"""Out-of-plane thickness of the specimen (mm)"""

pixel_area: npt.NDArray[np.float64]
"""Area per grid point, shape ``(y, x)`` (mm²)"""


class EEdgeCondition(enum.Enum):
"""Mechanical condition applied to an edge of the specimen"""

Free = enum.auto()
"""Unconstrained edge (stress-free)"""

Fixed = enum.auto()
"""Fully constrained edge (zero displacement)"""

Traction = enum.auto()
"""Edge with a known applied traction (force) applied"""


@dataclass(slots=True)
class Edge:
"""Boundary condition for the two orthogonal directions on a single edge"""

x: EEdgeCondition
"""Condition in the global x-direction"""

y: EEdgeCondition
"""Condition in the global y-direction"""


@dataclass(slots=True)
class EdgeConditions:
"""
Boundary conditions on the four edges of the specimen.

Edges are identified by the minimum/maximum coordinate value along each
axis
"""

min_x_edge: Edge
"""Condition along the minimum x edge"""

max_x_edge: Edge
"""Condition along the maximum x edge"""

min_y_edge: Edge
"""Condition along the minimum y edge"""

max_y_edge: Edge
"""Condition along the maximum y edge"""


@dataclass(slots=True)
class BoundaryConditions:
"""Combined kinematic and kinetic boundary conditions for the experiment"""

edge_conditions: EdgeConditions
"""Kinematic constraints on all four edges of the specimen"""

force: npt.NDArray[np.float64]
"""
Measured force history, shape ``(timesteps, 2)`` with columns
``[Fx, Fy]`` (x-direction, y-direction)
"""


def _calculate_timestep_deltas(
Expand All @@ -54,11 +111,57 @@ def _calculate_timestep_deltas(

@dataclass(slots=True)
class ExperimentData:
"""
Input data from a DIC experiment.

Stores the full-field strain history, specimen geometry, boundary
conditions, and temporal data needed to perform VFM identification.

Shape conventions
-----------------
strain (timesteps, components, y, x)
specimen_geometry:
x (y, x)
y (y, x)
pixel_area (y, x)
region_of_interest (y, x)
boundary_conditions:
force (timesteps, 2) ``[Fx, Fy]``
timesteps (timesteps,)

Coordinate system
-----------------
``x`` increases left to right (column index)
``y`` increases top to bottom (row index)
All coordinates are always positive, and start at 0.0

Notes
-----
``delta_timesteps`` is computed automatically from ``timesteps`` on init
and is not user-supplied
"""

strain: npt.NDArray[np.float64]
"""
Full-field strain history, shape ``(timesteps, components, y, x)``
where ``x`` increases left to right and ``y`` increases top to bottom.
Components are ordered as ``[xx, yy, xy]`` (normal x, normal y, shear xy)
"""

specimen_geometry: SpecimenGeometry
"""Geometry of the specimen"""

boundary_conditions: BoundaryConditions
"""Kinematic and kinetic boundary conditions applied during the test"""

timesteps: npt.NDArray[np.float64]
"""Time value at each frame / load step, shape ``(timesteps,)``"""

delta_timesteps: npt.NDArray[np.float64]
"""
Time increment between consecutive frames (computed automatically),
shape ``(timesteps,)``
"""

def __init__(
self,
Expand Down
Loading