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
1 change: 1 addition & 0 deletions docs/wiki/build-and-composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Generation writes sanitized `MjSpec.to_xml()` output after compiling each spec o
| `myohands` | `BOTH_HANDS` | Passive torso scaffold + right hand + mirrored left hand |
| `myofullbody` | `FULLBODY` | Full body: torso + mirrored arms + legs; free-floating root |
| `myolegs` | `LEGS_BODY` | Passive anatomical torso scaffold + legs |
| `myolegs26` | `LEGS26_BASE` | Reduced 26-muscle, legs-only base; free-floating root + `stand` keyframe (see [MyoLeg26](../../myo_sim/models/leg/README.md#myoleg26-reduced-26-muscle-legs-only)) |
| `myotorso_abdomen` | `TORSO_ABDOMEN` | Simple abdomen scaffold |
| `myolegs_abdomen` | `LEGS_ABDOMEN` | Minimal abdomen scaffold + legs; free-floating root |

Expand Down
44 changes: 39 additions & 5 deletions myo_sim/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import TYPE_CHECKING

from myo_sim.fragments import FragmentInfo as FragmentInfo, FragmentRegistry as FragmentRegistry

if TYPE_CHECKING:
import mujoco

try:
__version__ = version("myo-sim")
except PackageNotFoundError:
Expand Down Expand Up @@ -58,6 +62,7 @@ def get_xml_path(name: str) -> Path:
"myoarms",
"myofullbody",
"myolegs",
"myolegs26",
"myolegs_abdomen",
"myotorso",
"myotorso_abdomen",
Expand All @@ -79,16 +84,32 @@ def _left_hand_spec():
return load_left_hand_from_arm_spec()


# Maps fragment names to zero-arg callables returning MjSpec (hand-only, no torso
# scaffold). Used by downstream consumers (e.g. myosuite ModelBuilder) so that
# attach_fragment("hand") transparently routes through the compose pipeline instead
# of falling back to a bundled static XML. Includes myohand_l (left hand only);
# there is no myo_sim.load("myohand_l") alias — use this dict or myohands instead.
def _legs_spec():
from myo_sim.build.compose import load_legs_spec

return load_legs_spec()


def _legs26_spec():
from myo_sim.build.compose import load_legs26_spec

return load_legs26_spec()


# Maps fragment names to zero-arg callables returning an editable (uncompiled)
# MjSpec, with no torso scaffold. Used by downstream consumers (e.g. myosuite
# ModelBuilder, assist_sim) so that attaching a fragment routes through the compose
# pipeline instead of falling back to a bundled static XML. Includes myohand_l
# (left hand only); there is no myo_sim.load("myohand_l") alias — use this dict or
# myohands instead. Note "myolegs" here is the bare legs fragment; myo_sim.load(
# "myolegs") differs in that it attaches the legs to a passive torso scaffold.
FRAGMENT_SPEC_BUILDERS: dict[str, object] = {
"hand": _right_hand_spec,
"myohand": _right_hand_spec,
"myohand_r": _right_hand_spec,
"myohand_l": _left_hand_spec,
"myolegs": _legs_spec,
"myolegs26": _legs26_spec,
}


Expand Down Expand Up @@ -117,6 +138,19 @@ def load(name: str) -> tuple:
return model, mujoco.MjData(model)


def build_spec(name: str) -> "mujoco.MjSpec":
"""Return the uncompiled MjSpec for a composed model (mirror of load()).

Unlike load(), this stops at the editable MjSpec so downstream consumers
(e.g. assist_sim) can edit the model -- attach devices, delete bodies, add
actuators -- before compiling it themselves. Covers the generated composed
specs (myoarms, myotorso, myolegs, myolegs26, myofullbody).
"""
from myo_sim.build.compose import build_generated_model_spec

return build_generated_model_spec(name)


def get_path(rel: str) -> Path:
"""Return the absolute Path to a model file by relative path within MODELS_DIR.

Expand Down
62 changes: 62 additions & 0 deletions myo_sim/build/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from .utils import (
MirrorRules,
add_contact_pairs,
add_keyframes,
add_sensors,
build_child_xml_from_components,
build_mirrored_child_xml,
Expand All @@ -41,6 +42,7 @@
from utils import (
MirrorRules,
add_contact_pairs,
add_keyframes,
add_sensors,
build_child_xml_from_components,
build_mirrored_child_xml,
Expand Down Expand Up @@ -80,6 +82,11 @@
LEGS_TENDONS_XML = ROOT / "leg" / "assets" / "myolegs_tendon.xml"
LEGS_MUSCLES_XML = ROOT / "leg" / "assets" / "myolegs_muscle.xml"
LEGS_CHAIN_XML = ROOT / "leg" / "assets" / "myolegs_chain.xml"
LEGS26_ASSETS_XML = ROOT / "leg" / "assets" / "myolegs26_assets.xml"
LEGS26_TENDONS_XML = ROOT / "leg" / "assets" / "myolegs26_tendon.xml"
LEGS26_MUSCLES_XML = ROOT / "leg" / "assets" / "myolegs26_muscle.xml"
LEGS26_CHAIN_XML = ROOT / "leg" / "assets" / "myolegs26_chain.xml"
LEGS26_KEYFRAMES_XML = ROOT / "leg" / "assets" / "myolegs26_keyframes.xml"

TORSO_ROOT_BODY = "Torso"
RIGHT_ARM_ATTACH_SITE = "arm_attach_r"
Expand All @@ -99,6 +106,7 @@ class BuildStrategy(str, Enum):
BOTH_HANDS = "both_hands"
FULLBODY = "fullbody"
LEGS_BODY = "legs_body"
LEGS26_BASE = "legs26_base"
TORSO_ABDOMEN = "torso_abdomen"
LEGS_ABDOMEN = "legs_abdomen"

Expand Down Expand Up @@ -187,6 +195,15 @@ class ModelRegistration:
include_arm_contacts=False,
include_legs=True,
),
"myolegs26": ModelRegistration(
name="myolegs26",
build_strategy=BuildStrategy.LEGS26_BASE,
left_arm_strategy=LEFT_ARM_STRATEGY_NONE,
description="Reduced 26-muscle legs-only base (free root, no torso)",
include_left_arm_contacts=False,
include_arm_contacts=False,
include_legs=True,
),
"myolegs_abdomen": ModelRegistration(
name="myolegs_abdomen",
build_strategy=BuildStrategy.LEGS_ABDOMEN,
Expand Down Expand Up @@ -331,6 +348,29 @@ def load_legs_spec() -> mujoco.MjSpec:
return legs


def load_legs26_spec(include_scene: bool = False) -> mujoco.MjSpec:
"""Load the reduced 26-muscle, legs-only chain (no torso scaffold).

include_scene attaches the standard myosuite scene (floor + lights) for the
standalone base; the bare fragment leaves it off so the chain can be attached
or merged without a stray floor.
"""
legs_xml = build_child_xml_from_components(
model_name="myolegs26_attach",
compiler_meshdir=ROOT,
assets_xml=LEGS26_ASSETS_XML,
tendons_xml=LEGS26_TENDONS_XML,
muscles_xml=LEGS26_MUSCLES_XML,
chain_xml=LEGS26_CHAIN_XML,
root_body_name="myolegs26_root",
root_site_name="legs26_root_attach",
scene_xmls=(SCENE_XML,) if include_scene else (),
)
legs = mujoco.MjSpec.from_string(legs_xml)
legs.compiler.balanceinertia = True
return legs


def load_torso_abdomen_spec() -> mujoco.MjSpec:
abdomen_xml = build_child_xml_from_components(
model_name="myotorso_abdomen_attach",
Expand Down Expand Up @@ -471,6 +511,26 @@ def build_legs_body_model(registration: ModelRegistration) -> mujoco.MjModel:
return build_legs_body_spec(registration).compile()


def build_legs26_base_spec(registration: ModelRegistration) -> mujoco.MjSpec:
"""Standalone legs-only 26-muscle base: the chain with a free root joint and the
standard scene (floor + lights), no torso. Ships a ``stand`` keyframe (upright on the
pedestal, facing the myo_sim heading) because the joint couplers cannot be satisfied
at qpos0. Downstream consumers that supply their own ground (e.g. assist_sim) are
expected to strip the scene."""
legs = load_legs26_spec(include_scene=True)
root_body = find_body(legs, "myolegs26_root")
root_body.add_freejoint(name="root")
root_body.quat = [0.70710678, 0.0, 0.0, -0.70710678] # -90deg yaw about world z (qpos0 heading)
# Keyframe(s) are applied after the free root exists so the qpos layout matches
# (the bare fragment from load_legs26_spec is intentionally keyframe-less).
add_keyframes(legs, LEGS26_KEYFRAMES_XML)
return legs


def build_legs26_base_model(registration: ModelRegistration) -> mujoco.MjModel:
return build_legs26_base_spec(registration).compile()


def build_torso_abdomen_model(registration: ModelRegistration) -> mujoco.MjModel:
abdomen = load_torso_abdomen_spec()
root_body = find_body(abdomen, "root")
Expand Down Expand Up @@ -501,6 +561,7 @@ def build_legs_abdomen_model(registration: ModelRegistration) -> mujoco.MjModel:
BuildStrategy.BOTH_HANDS: build_both_hands_from_arm_model,
BuildStrategy.FULLBODY: build_fullbody_model,
BuildStrategy.LEGS_BODY: build_legs_body_model,
BuildStrategy.LEGS26_BASE: build_legs26_base_model,
BuildStrategy.TORSO_ABDOMEN: build_torso_abdomen_model,
BuildStrategy.LEGS_ABDOMEN: build_legs_abdomen_model,
}
Expand All @@ -509,6 +570,7 @@ def build_legs_abdomen_model(registration: ModelRegistration) -> mujoco.MjModel:
"myoarms": build_arms_body_spec,
"myotorso": build_torso_body_spec,
"myolegs": build_legs_body_spec,
"myolegs26": build_legs26_base_spec,
"myofullbody": build_fullbody_spec,
}

Expand Down
18 changes: 18 additions & 0 deletions myo_sim/build/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,24 @@ def add_sensors(spec: object, sensors_xml: Path) -> None:
)


def add_keyframes(spec: object, keyframes_xml: Path) -> None:
"""Add keyframes from an MJCF include file.

Kept as a spec-level step (not merged into the composed child XML) because a
keyframe's ``qpos`` is only valid once the model's DoFs are fixed -- e.g. for
a standalone base, after the free root joint has been added.
"""
for key in ET.parse(keyframes_xml).getroot().iter("key"):
added = spec.add_key()
added.name = key.get("name")
if key.get("qpos"):
added.qpos = float_list(key.get("qpos"))
if key.get("qvel"):
added.qvel = float_list(key.get("qvel"))
if key.get("time"):
added.time = float(key.get("time"))


def expand_component_element(element: ET.Element, base_path: Path) -> list[ET.Element]:
"""Expand local MJCF include elements inside a component tree."""
if element.tag == "include":
Expand Down
40 changes: 40 additions & 0 deletions myo_sim/models/leg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,46 @@ Example fidelity check (glmax1 left/right moment-arm and force-length plot) for

**myoleg_v0.51 (mj120)** — Added new keyposes to mark convenient poses.

## MyoLeg26 (reduced 26-muscle, legs-only)

`myolegs26` is a reduced, lower-limb model (no HAT, torso, arms, or head). It is meant as a compact
base for work where the full 80-muscle anatomy
isn't needed.

### Anatomical scope

| Property | Value |
|---|---|
| Degrees of freedom | 18 (46 incl. equality-coupled moving-via-point DoFs) |
| Actuators (muscles) | 26 |
| Body segments | calcn_l, calcn_r, femur_l, femur_r, pelvis, talus_l, talus_r, tibia_l, tibia_r, toes_l, toes_r |
| Primary joints | hip_flexion, hip_adduction, hip_rotation, knee_angle, ankle_angle, mtp_angle (bilateral) |


### Build

Composed at runtime (there is no static `myolegs26.xml`), from
`myo_sim/build/compose.py`:

- **Standalone base** — `build_legs26_base_spec`, exposed via `build_model("myolegs26")`, `build_spec("myolegs26")`, and `load("myolegs26")`. It assembles the component files, adds a **free root joint** (myosuite heading), layers the standard myosuite **scene** (floor + lights), and applies the **`stand` keyframe** loaded from `myolegs26_keyframes.xml`. The stand pose is shipped as a keyframe (not `qpos0`) because the joint couplers cannot be satisfied at `qpos0`; it is fully at-rest (coupler residual ≈ 0, feet on the pedestal).
- **Bare fragment** — `FRAGMENT_SPEC_BUILDERS["myolegs26"]` returns just the chain + muscles/tendons/assets, with **no** free root, scene, or keyframe, for composing into larger models or for downstream consumers (e.g. `assist_sim`) that supply their own root/ground and poses.

```python
import myo_sim

model, data = myo_sim.load("myolegs26") # compiled MjModel + MjData (standalone base)
spec = myo_sim.build_spec("myolegs26") # editable MjSpec of the standalone base
```

### Reference & credits

Reduced from the OpenSim gait2392 / gait9dof18 lineage (Ajay Seth, based on
Delp et al. 1990; muscle strengths after Handsfield/Rajagopal, tuned by
Carmichael Ong; planar knee after Yamaguchi & Zajac 1989). Adapted for MyoLeg
by Chun Kwang Tan (sagittal-plane joints, ankle ROM, GRF foot sensors) and
extended by Calder Robbins (toes + mtp joints, EDL/FDL muscles). License:
CC-BY 3.0.

## Citation

See repository README.
Loading
Loading