初版:实现 AMESim 机械因果化与事件求解

初步支持 MECMAS21 刚性质量状态归并、端止事件、恢复系数,以及 LSTP 接触和压力流量显式因果化。

已知问题:显式传播仍会重复扫描全网方程,长时刚性仿真性能待优化;自适应积分器遇到越出物理域的试探状态时,尚未实现恢复并缩步重试。
This commit is contained in:
ljz committed 2026-08-03 15:45:48 +08:00
1 parent de265cdde6
commit 971e8f2336
9 files changed
+2808 -169

No files matched your search

+560 -115
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite, sqrt
from math import expm1, isfinite, log, sqrt
from app.simulation.core.ports import PortState, VariableRole
from app.simulation.systems.network import SimulationNetwork
@@ -32,6 +32,22 @@ class AlgebraicUnknown:
setattr(self.state, self.variable, float(value))
@dataclass(frozen=True)
class EffortEqualityGroup:
variable: str
members: tuple[AlgebraicUnknown, ...]
anchors: tuple[tuple[AlgebraicUnknown, float], ...]
@dataclass(frozen=True)
class UnilateralContactBinding:
component: object
algebraic_group: EffortEqualityGroup
neighbor_force: AlgebraicUnknown
algebraic_port: int
force_sign: float
@dataclass(frozen=True)
class AlgebraicSolveDiagnostics:
success: bool
@@ -102,26 +118,33 @@ class PressureFlowSolver:
return None
return component_name, port_name
def _seed_equal_pressures(self) -> None:
"""Lift current state pressures across their complete equality groups.
def _seed_equal_efforts(self) -> None:
"""Lift state-owned efforts across their complete equality groups.
Dynamic components refresh their own pressure ports before each closure,
while connected algebraic ports retain values from the preceding RHS
evaluation. Merely filling non-positive pressures therefore leaves a
stale, and sometimes badly conditioned, nonlinear initial guess. State
equations expose the current pressure as ``port.p - target``; use that
target as the authoritative anchor for every connected/equal port.
Dynamic components refresh their own ports before each closure, while
connected algebraic ports retain values from the preceding RHS
evaluation. State equations expose the current effort as
``port.variable - target``; use that target as the authoritative anchor
for every connected/equal pressure, displacement, and velocity port
before evaluating explicit flow laws.
"""
pressure_unknowns = {
for variable in ("p", "x", "v"):
self._seed_equal_effort(variable)
def _effort_equality_groups(
self,
variable: str,
) -> tuple[EffortEqualityGroup, ...]:
effort_unknowns = {
(unknown.component, unknown.port): unknown
for unknown in self.unknowns
if unknown.variable == "p"
if unknown.variable == variable
}
if not pressure_unknowns:
return
if not effort_unknowns:
return ()
parent = {key: key for key in pressure_unknowns}
parent = {key: key for key in effort_unknowns}
def find(key: tuple[str, str]) -> tuple[str, str]:
root = key
@@ -144,7 +167,7 @@ class PressureFlowSolver:
continue
first = connection.endpoint_a.key
second = connection.endpoint_b.key
if first in pressure_unknowns and second in pressure_unknowns:
if first in effort_unknowns and second in effort_unknowns:
union(first, second)
component_equations = {
@@ -157,155 +180,532 @@ class PressureFlowSolver:
continue
endpoints = [
endpoint
for variable in equation.variables
for equation_variable in equation.variables
if (
(endpoint := self._port_key(variable, "p"))
in pressure_unknowns
(endpoint := self._port_key(equation_variable, variable))
in effort_unknowns
)
]
for endpoint in endpoints[1:]:
union(endpoints[0], endpoint)
members_by_root: dict[tuple[str, str], list[tuple[str, str]]] = {}
for endpoint in pressure_unknowns:
members_by_root.setdefault(find(endpoint), []).append(endpoint)
members_by_root: dict[tuple[str, str], list[AlgebraicUnknown]] = {}
for endpoint in effort_unknowns:
members_by_root.setdefault(find(endpoint), []).append(
effort_unknowns[endpoint]
)
anchors_by_root: dict[tuple[str, str], list[float]] = {}
anchors_by_root: dict[
tuple[str, str],
list[tuple[AlgebraicUnknown, float]],
] = {}
for equations in component_equations.values():
for equation in equations:
if equation.relation != "state" or equation.role != "effort":
continue
endpoints = [
endpoint
for variable in equation.variables
for equation_variable in equation.variables
if (
(endpoint := self._port_key(variable, "p"))
in pressure_unknowns
(endpoint := self._port_key(equation_variable, variable))
in effort_unknowns
)
]
if len(endpoints) != 1:
continue
endpoint = endpoints[0]
unknown = pressure_unknowns[endpoint]
target_pressure = unknown.read() - float(equation.value)
if not isfinite(target_pressure):
unknown = effort_unknowns[endpoint]
target_value = unknown.read() - float(equation.value)
if not isfinite(target_value):
continue
# Keep the state-owned port current even when an invalid model
# has conflicting storage anchors in one equality group.
unknown.write(target_pressure)
anchors_by_root.setdefault(find(endpoint), []).append(target_pressure)
anchors_by_root.setdefault(find(endpoint), []).append(
(unknown, target_value)
)
for root, members in members_by_root.items():
anchors = anchors_by_root.get(root, [])
return tuple(
EffortEqualityGroup(
variable=variable,
members=tuple(members),
anchors=tuple(anchors_by_root.get(root, ())),
)
for root, members in members_by_root.items()
)
def _seed_equal_effort(self, variable: str) -> None:
for group in self._effort_equality_groups(variable):
members = group.members
anchors = group.anchors
if anchors:
pressure_scale = max([abs(value) for value in anchors] + [1.0])
if max(anchors) - min(anchors) > 1.0e-9 * pressure_scale:
# Keep each state-owned port current even when an invalid model
# has conflicting anchors in one equality group.
for unknown, target_value in anchors:
unknown.write(target_value)
anchor_values = [value for _unknown, value in anchors]
effort_scale = max([abs(value) for value in anchor_values] + [1.0])
if max(anchor_values) - min(anchor_values) > 1.0e-9 * effort_scale:
# A conflicting multi-storage group is structurally invalid;
# leave it for the residual solver/preparation diagnostics.
continue
target_pressure = sum(anchors) / len(anchors)
for endpoint in members:
pressure_unknowns[endpoint].write(target_pressure)
target_value = sum(anchor_values) / len(anchor_values)
for unknown in members:
unknown.write(target_value)
continue
positive_seed = next(
(
pressure_unknowns[endpoint].read()
for endpoint in members
if pressure_unknowns[endpoint].read() > 0.0
),
None,
if variable == "p":
seed = next(
(
unknown.read()
for unknown in members
if unknown.read() > 0.0
),
None,
)
if seed is None:
continue
else:
seed = members[0].read()
for unknown in members:
if variable != "p" or unknown.read() <= 0.0:
unknown.write(seed)
def _connected_flow_unknown(
self,
component_name: str,
port_name: str,
variable: str,
) -> AlgebraicUnknown | None:
endpoint_key = (component_name, port_name)
for connection in self.network.connections:
if connection.kind != "physical":
continue
if connection.endpoint_a.key == endpoint_key:
other = connection.endpoint_b
elif connection.endpoint_b.key == endpoint_key:
other = connection.endpoint_a
else:
continue
return self._unknowns_by_id.get(
f"{other.component}.{other.port}.{variable}"
)
if positive_seed is None:
return None
@staticmethod
def _bisect_contact_root(
value_at,
lower: float,
upper: float,
target: float,
) -> float | None:
lower_value = float(value_at(lower)) - target
upper_value = float(value_at(upper)) - target
tolerance = 1.0e-13 * max(abs(target), 1.0)
if abs(lower_value) <= tolerance:
return lower
if abs(upper_value) <= tolerance:
return upper
if not isfinite(lower_value) or not isfinite(upper_value):
return None
if (lower_value < 0.0) == (upper_value < 0.0):
return None
for _iteration in range(100):
middle = 0.5 * (lower + upper)
middle_value = float(value_at(middle)) - target
if abs(middle_value) <= tolerance:
return middle
if (lower_value < 0.0) == (middle_value < 0.0):
lower = middle
lower_value = middle_value
else:
upper = middle
upper_value = middle_value
return 0.5 * (lower + upper)
def _contact_penetration_for_force(
self,
component,
requested_force: float,
current_penetration: float,
) -> float | None:
"""Invert one LSTP force law and select the root nearest its current state."""
if not isfinite(requested_force):
return None
option = int(getattr(component, "discContactOption", 2.0))
if option != 1:
requested_force = max(requested_force, 0.0)
stiffness = max(float(getattr(component, "kcont", 0.0)), 0.0)
damping = max(float(getattr(component, "rcont", 0.0)), 0.0)
damping_length = float(getattr(component, "Pdis", 0.0))
relative_velocity = float(getattr(component, "penetration_velocity"))
damping_term = damping * relative_velocity
current_penetration = (
max(float(current_penetration), 0.0)
if isfinite(current_penetration)
else 0.0
)
force_tolerance = 1.0e-12 * max(abs(requested_force), 1.0)
def raw_force(penetration: float) -> float:
if penetration <= 0.0:
return 0.0
damping_fraction = (
-expm1(-penetration / damping_length)
if damping_length > 0.0
else 1.0
)
return stiffness * penetration + damping_term * damping_fraction
def contact_force(penetration: float) -> float:
force = raw_force(penetration)
return force if option == 1 else max(force, 0.0)
candidates: list[float] = []
def add_candidate(penetration: float | None) -> None:
if penetration is None or not isfinite(penetration) or penetration < 0.0:
return
if abs(contact_force(penetration) - requested_force) > force_tolerance:
return
if not any(
abs(penetration - candidate)
<= 1.0e-12 * max(abs(penetration), abs(candidate), 1.0e-18)
for candidate in candidates
):
candidates.append(penetration)
add_candidate(current_penetration)
add_candidate(0.0)
if option != 1 and requested_force == 0.0:
return min(
candidates or [0.0],
key=lambda penetration: abs(penetration - current_penetration),
)
if damping_length <= 0.0:
if stiffness > 0.0:
penetration = (requested_force - damping_term) / stiffness
if penetration > 0.0:
add_candidate(penetration)
elif abs(requested_force - damping_term) <= force_tolerance:
add_candidate(max(current_penetration, 1.0e-18))
elif stiffness > 0.0:
critical_penetration: float | None = None
if damping_term < -stiffness * damping_length:
critical_penetration = damping_length * log(
-damping_term / (stiffness * damping_length)
)
add_candidate(critical_penetration)
upper = max(
current_penetration,
damping_length,
abs(requested_force) / stiffness,
critical_penetration or 0.0,
1.0e-18,
)
for _iteration in range(100):
upper_value = raw_force(upper)
if isfinite(upper_value) and upper_value >= requested_force:
break
upper *= 2.0
else:
upper = float("nan")
if isfinite(upper):
if critical_penetration is not None:
add_candidate(
self._bisect_contact_root(
raw_force,
0.0,
critical_penetration,
requested_force,
)
)
add_candidate(
self._bisect_contact_root(
raw_force,
critical_penetration,
upper,
requested_force,
)
)
else:
add_candidate(
self._bisect_contact_root(
raw_force,
0.0,
upper,
requested_force,
)
)
elif damping_term != 0.0:
upper = max(current_penetration, damping_length, 1.0e-18)
for _iteration in range(100):
upper_value = raw_force(upper)
crossed = (
upper_value >= requested_force
if damping_term > 0.0
else upper_value <= requested_force
)
if isfinite(upper_value) and crossed:
add_candidate(
self._bisect_contact_root(
raw_force,
0.0,
upper,
requested_force,
)
)
break
upper *= 2.0
if not candidates:
return None
return min(
candidates,
key=lambda penetration: abs(penetration - current_penetration),
)
def _apply_unilateral_contact_binding(
self,
binding: UnilateralContactBinding,
) -> bool:
component = binding.component
requested_force = binding.force_sign * binding.neighbor_force.read()
if int(getattr(component, "discContactOption", 2.0)) != 1:
requested_force = max(requested_force, 0.0)
cached_penetration = getattr(component, "_causal_penetration", None)
penetration = self._contact_penetration_for_force(
component,
requested_force,
(
float(cached_penetration)
if cached_penetration is not None
else float(getattr(component, "penetration"))
),
)
if penetration is None:
component.clear_causal_contact()
return False
gap0 = float(getattr(component, "gap0", 0.0))
if binding.algebraic_port == 1:
target = component.port_2.x - gap0 - penetration
else:
target = component.port_1.x + gap0 + penetration
for unknown in binding.algebraic_group.members:
unknown.write(target)
component.set_causal_contact(
penetration=penetration,
force=requested_force,
)
return True
def _refresh_unilateral_contacts(
self,
bindings: tuple[UnilateralContactBinding, ...],
) -> None:
for binding in bindings:
self._apply_unilateral_contact_binding(binding)
def _seed_unilateral_contacts(
self,
) -> tuple[UnilateralContactBinding, ...]:
"""Create local eliminations for contacts with one algebraic coordinate."""
position_groups = {
unknown.id: group
for group in self._effort_equality_groups("x")
for unknown in group.members
}
bindings: list[UnilateralContactBinding] = []
bound_group_ids: set[int] = set()
for component in self.network.components.values():
if component.model_type != "amesim_lstp00a":
continue
for endpoint in members:
unknown = pressure_unknowns[endpoint]
if unknown.read() <= 0.0:
unknown.write(positive_seed)
first_neighbor = self._connected_flow_unknown(
component.name,
"port_1",
"f",
)
second_neighbor = self._connected_flow_unknown(
component.name,
"port_2",
"f",
)
first_group = position_groups.get(f"{component.name}.port_1.x")
second_group = position_groups.get(f"{component.name}.port_2.x")
if (
first_group is None
or second_group is None
or first_group is second_group
):
continue
if not first_group.anchors and first_neighbor is not None:
binding = UnilateralContactBinding(
component=component,
algebraic_group=first_group,
neighbor_force=first_neighbor,
algebraic_port=1,
force_sign=1.0,
)
elif not second_group.anchors and second_neighbor is not None:
binding = UnilateralContactBinding(
component=component,
algebraic_group=second_group,
neighbor_force=second_neighbor,
algebraic_port=2,
force_sign=-1.0,
)
else:
# With both coordinates state-owned, penetration is a dynamic
# result rather than an algebraic active-set choice.
continue
group_id = id(binding.algebraic_group)
if group_id in bound_group_ids:
# One relative contact law may eliminate a free coordinate.
# Any other contact sharing that coordinate must remain in the
# nonlinear system or the projections would overwrite each
# other and make root selection order-dependent.
continue
if self._apply_unilateral_contact_binding(binding):
bindings.append(binding)
bound_group_ids.add(group_id)
def _seed_explicit_mass_flows(self) -> None:
"""Initialize explicit ``m_flow - f(...)`` constitutive relations.
return tuple(bindings)
AMESim orifices and quasi-steady pneumatic lines expose one mass-flow
unknown with unit coefficient. Once pressure anchors are current, a
residual correction places that flow directly on its constitutive
surface and avoids asking the nonlinear optimizer to discover the
square-root branch from a stale preceding-step value.
def _solve_explicit_flow_unknowns(self) -> set[str]:
"""Directly evaluate explicit flow variables before nonlinear closure.
Component constitutive equations use the normalized residual form
``flow_unknown + remainder = 0`` whenever exactly one physical flow
variable is present. Solve those relations by substitution first,
then propagate the known values through component balances and physical
connectors. This covers pneumatic ``m_flow`` variables as well as
mechanical forces ``f`` such as ``FORC`` without asking the nonlinear
optimizer to discover values many orders of magnitude away from zero.
The remaining coupled equations still go through ``least_squares``;
these assignments provide both a consistent initial guess and the
nominal magnitudes used to scale that smaller nonlinear problem.
"""
seeded_ids: set[str] = set()
# Mechanical reaction balances can contain null-space forces. Reusing
# an arbitrary least-squares distribution from the preceding RHS call
# makes contact activation history-dependent, so choose deterministic
# zero tear values and rebuild the force chain from current signals,
# states, and pressure loads on every closure.
for unknown in self.unknowns:
if unknown.variable == "f":
unknown.write(0.0)
# First evaluate constitutive relations that expose one flow unknown
# with unit coefficient. Other variables in the equation (pressure,
# displacement, velocity, or a signal) have already been refreshed for
# the current state and time by the staged system closure.
for component in self.network.components.values():
for equation in component.pressure_flow_equation_residuals():
if equation.relation != "constitutive" or equation.role != "flow":
continue
mass_flow_unknowns = [
flow_unknowns = [
self._unknowns_by_id[variable]
for variable in equation.variables
if variable in self._unknowns_by_id
and self._unknowns_by_id[variable].variable == "m_flow"
and self._unknowns_by_id[variable].role == "flow"
]
if len(mass_flow_unknowns) != 1:
if len(flow_unknowns) != 1:
continue
unknown = mass_flow_unknowns[0]
target_flow = unknown.read() - float(equation.value)
if not isfinite(target_flow):
unknown = flow_unknowns[0]
if unknown.id in seeded_ids:
continue
unknown.write(target_flow)
target_value = unknown.read() - float(equation.value)
if not isfinite(target_value):
continue
unknown.write(target_value)
seeded_ids.add(unknown.id)
# Complete local two-port balances for explicit elements. Connection
# flow equations remain available to align the adjacent component port.
for component in self.network.components.values():
for equation in component.pressure_flow_equation_residuals():
if equation.relation != "sumToZero" or equation.role != "flow":
# V1/correctness-first implementation: repeatedly solve any balance that
# now has exactly one unknown flow variable left. Rebuilding and
# rescanning the complete residual tuple after every assignment keeps
# propagation deterministic, but costs O(flow unknowns * equations) and
# can dominate long, stiff simulations. A production follow-up should
# compile the assignment/tear order from the static topology once and
# evaluate only each owning component or connection residual here.
while True:
propagated = False
for equation in self.network.pressure_flow_equation_residuals():
if equation.role != "flow" or equation.relation not in {
"constitutive",
"sumToZero",
}:
continue
mass_flow_unknowns = [
flow_unknowns = [
self._unknowns_by_id[variable]
for variable in equation.variables
if variable in self._unknowns_by_id
and self._unknowns_by_id[variable].variable == "m_flow"
and self._unknowns_by_id[variable].role == "flow"
]
if len(mass_flow_unknowns) != 2:
if not flow_unknowns:
continue
seeded = [
unknown for unknown in mass_flow_unknowns if unknown.id in seeded_ids
variable_names = {unknown.variable for unknown in flow_unknowns}
if len(variable_names) != 1:
continue
unseeded = [
unknown for unknown in flow_unknowns if unknown.id not in seeded_ids
]
if len(seeded) != 1:
if len(unseeded) != 1:
continue
other = next(
unknown for unknown in mass_flow_unknowns if unknown.id not in seeded_ids
)
other.write(-seeded[0].read())
seeded_ids.add(other.id)
unknown = unseeded[0]
target_value = unknown.read() - float(equation.value)
if not isfinite(target_value):
continue
unknown.write(target_value)
seeded_ids.add(unknown.id)
propagated = True
break
if not propagated:
# Causalize one remaining free flow in an otherwise normalized
# linear balance. This is the algebraic equivalent of choosing
# a tear variable: the other free flows retain their current
# guesses and one dependent flow closes the equation exactly.
# It also gives rank-deficient rigid-body reaction balances a
# deterministic starting point before state reduction supplies
# their common acceleration.
for equation in self.network.pressure_flow_equation_residuals():
if equation.role != "flow" or equation.relation not in {
"constitutive",
"sumToZero",
}:
continue
flow_unknowns = [
self._unknowns_by_id[variable]
for variable in equation.variables
if variable in self._unknowns_by_id
and self._unknowns_by_id[variable].role == "flow"
]
unseeded = [
unknown
for unknown in flow_unknowns
if unknown.id not in seeded_ids
]
if len(unseeded) <= 1:
continue
if len({unknown.variable for unknown in flow_unknowns}) != 1:
continue
unknown = unseeded[-1]
target_value = unknown.read() - float(equation.value)
if not isfinite(target_value):
continue
unknown.write(target_value)
seeded_ids.add(unknown.id)
propagated = True
break
if not propagated:
break
# A physical connector imposes the same sum-to-zero flow rule as a
# two-port component. Once an explicit component flow is known, carry
# that guess to the connected storage/boundary port as well. For the
# common volume-orifice-volume topology this makes the seeded state an
# exact algebraic solution and avoids an unnecessary nonlinear solve on
# every ODE/Jacobian evaluation.
for connection in self.network.connections:
if connection.kind != "physical":
continue
endpoint_unknowns = []
for endpoint in connection.endpoints:
unknown = self._unknowns_by_id.get(
f"{endpoint.component}.{endpoint.port}.m_flow"
)
if unknown is not None:
endpoint_unknowns.append(unknown)
if len(endpoint_unknowns) != 2:
continue
seeded = [
unknown for unknown in endpoint_unknowns if unknown.id in seeded_ids
]
if len(seeded) != 1:
continue
other = next(
unknown for unknown in endpoint_unknowns if unknown.id not in seeded_ids
)
other.write(-seeded[0].read())
seeded_ids.add(other.id)
return seeded_ids
def _scales(self) -> dict[str, float]:
pressure_scale = max(
@@ -356,11 +756,28 @@ class PressureFlowSolver:
"Topology-driven simulation requires SciPy; install requirements.txt."
) from exc
self._seed_equal_pressures()
self._seed_explicit_mass_flows()
for component in self.network.components.values():
clear_causal_contact = getattr(component, "clear_causal_contact", None)
if clear_causal_contact is not None:
clear_causal_contact()
self._seed_equal_efforts()
self._solve_explicit_flow_unknowns()
contact_bindings = self._seed_unilateral_contacts()
if contact_bindings:
self._solve_explicit_flow_unknowns()
self._refresh_unilateral_contacts(contact_bindings)
scales = self._scales()
pressure_scale = scales["p"]
flow_scale = scales["m_flow"]
unknown_scales = {
unknown.id: (
max(abs(unknown.read()), 1.0)
if unknown.variable == "f"
else scales.get(unknown.variable, max(abs(unknown.read()), 1.0))
)
for unknown in self.unknowns
}
positive_pressures = [
unknown.read()
for unknown in self.unknowns
@@ -373,15 +790,28 @@ class PressureFlowSolver:
)
def variable_scale(unknown: AlgebraicUnknown) -> float:
return scales.get(unknown.variable, max(abs(unknown.read()), 1.0))
return unknown_scales[unknown.id]
def equation_scale(equation) -> float:
seeded_equations = self.network.pressure_flow_equation_residuals()
def initial_equation_scale(equation) -> float:
variable_names = [
variable.rsplit(".", 1)[-1]
for variable in equation.variables
]
if equation.role == "flow":
return scales["f"] if "f" in variable_names else flow_scale
force_scales = [
unknown_scales[variable]
for variable in equation.variables
if variable in self._unknowns_by_id
and self._unknowns_by_id[variable].variable == "f"
]
if force_scales:
# Freeze force scaling per equation. A 1e17 N source must
# not hide an unrelated 40 N piston/contact imbalance in a
# different mechanical branch.
return max(force_scales + [abs(float(equation.value)), 1.0])
return flow_scale
if equation.role == "effort":
if "x" in variable_names:
return scales["x"]
@@ -390,7 +820,14 @@ class PressureFlowSolver:
return pressure_scale
return max([scales.get(name, 1.0) for name in variable_names] + [1.0])
seeded_equations = self.network.pressure_flow_equation_residuals()
equation_scales = {
equation.id: initial_equation_scale(equation)
for equation in seeded_equations
}
def equation_scale(equation) -> float:
return equation_scales.get(equation.id, initial_equation_scale(equation))
seeded_scaled = [
abs(equation.value / equation_scale(equation))
for equation in seeded_equations
@@ -424,6 +861,12 @@ class PressureFlowSolver:
self.last_diagnostics = diagnostics
return diagnostics
# A causal contact retains its small relative penetration around the
# current absolute port coordinates. Keep that local coordinate during
# nonlinear fallback: the contact law remains responsive to optimizer
# increments, while a sub-ULP penetration is not lost by subtracting two
# large absolute displacements.
x0 = np.asarray(
[
(
@@ -450,6 +893,7 @@ class PressureFlowSolver:
def scaled_residuals(values):
assign(values)
self._refresh_unilateral_contacts(contact_bindings)
equations = self.network.pressure_flow_equation_residuals()
return np.asarray(
[
@@ -470,6 +914,7 @@ class PressureFlowSolver:
max_nfev=self.max_evaluations,
)
assign(result.x)
self._refresh_unilateral_contacts(contact_bindings)
equations = self.network.pressure_flow_equation_residuals()
scaled = [
abs(