Skip to content

Categorize

Discrete emotion categorization via Plutchik's wheel (Plutchik, 1980).

Maps continuous (valence, arousal) coordinates from the Russell circumplex to one of 8 primary emotions with three intensity tiers. An optional dominance value disambiguates the fear/anger region following the PAD model (Mehrabian & Russell, 1974).

emotional_memory.categorize.EmotionLabel

Bases: BaseModel

Discrete emotion label derived from continuous affect coordinates.

primary — one of Plutchik's 8 primary emotions intensity — low / moderate / high (Plutchik intensity tiers) name — canonical dyadic tier name (e.g. "serenity", "joy", "ecstasy") confidence — how close the point is to the sector centre [0, 1]

emotional_memory.categorize.categorize_affect(core_affect: CoreAffect, dominance: float | None = None) -> EmotionLabel

Map a CoreAffect point to a discrete Plutchik emotion label.

Parameters:

Name Type Description Default
core_affect CoreAffect

The continuous valence-arousal point.

required
dominance float | None

Optional PAD dominance value [0, 1]. When provided, disambiguates fear vs anger in the high-arousal / negative-valence region.

None

Returns:

Name Type Description
An EmotionLabel

class:EmotionLabel with the primary emotion, intensity tier,

EmotionLabel

canonical tier name, and sector-centre confidence.

Notes

Arousal is re-centred to [-0.5, 0.5] and then scaled *2 to [-1, 1] so that both axes have equal span. This makes the 45° sectors isotropic in the valence-arousal plane.

Points within radius _NEUTRAL_RADIUS of the origin are too ambiguous to categorise reliably; they are returned with confidence=0.0.

Source code in src/emotional_memory/categorize.py
def categorize_affect(
    core_affect: CoreAffect,
    dominance: float | None = None,
) -> EmotionLabel:
    """Map a CoreAffect point to a discrete Plutchik emotion label.

    Args:
        core_affect: The continuous valence-arousal point.
        dominance:   Optional PAD dominance value [0, 1].  When provided,
                     disambiguates fear vs anger in the high-arousal /
                     negative-valence region.

    Returns:
        An :class:`EmotionLabel` with the primary emotion, intensity tier,
        canonical tier name, and sector-centre confidence.

    Notes:
        Arousal is re-centred to [-0.5, 0.5] and then scaled *2 to [-1, 1]
        so that both axes have equal span.  This makes the 45° sectors
        isotropic in the valence-arousal plane.

        Points within radius ``_NEUTRAL_RADIUS`` of the origin are too
        ambiguous to categorise reliably; they are returned with
        ``confidence=0.0``.
    """
    v = core_affect.valence
    # Re-centre arousal to [-0.5, 0.5], then scale to [-1, 1] for isotropy.
    a_scaled = (core_affect.arousal - 0.5) * 2.0

    # Radial distance in the isotropic space (max ≈ sqrt(2) ≈ 1.41)
    r = math.sqrt(v**2 + a_scaled**2)

    # Angular position in degrees, normalised to [0°, 360°)
    angle_deg = math.degrees(math.atan2(a_scaled, v))
    angle_norm = angle_deg % 360.0  # [0, 360)

    # Determine sector index (0-7)
    sector_idx = int((angle_norm + 22.5) / 45.0) % 8

    # Resolve primary emotion
    primary: PrimaryEmotion = _SECTOR_EMOTION[sector_idx]
    if sector_idx == _FEAR_ANGER_SECTOR and dominance is not None:
        primary = "anger" if dominance >= _DOMINANCE_THRESHOLD else "fear"

    # A near-baseline state (neutral valence + minimal arousal, i.e. around
    # CoreAffect.neutral()) is affectively flat: too ambiguous to categorise. Flag
    # it as low-intensity, zero-confidence rather than a confident high-arousal
    # emotion produced by the arousal re-centring.
    at_baseline = abs(v) < _NEUTRAL_RADIUS and core_affect.arousal < _BASELINE_AROUSAL_EPS
    is_neutral = r < _NEUTRAL_RADIUS or at_baseline

    # Intensity from radial distance
    if is_neutral or r < _INTENSITY_LOW_THRESHOLD:
        intensity: Intensity = "low"
    elif r < _INTENSITY_HIGH_THRESHOLD:
        intensity = "moderate"
    else:
        intensity = "high"

    # Confidence: 1.0 at sector centre, 0.0 at sector edge (±22.5°).
    # Near-origin and near-baseline points are unreliable — confidence forced to 0.0.
    if is_neutral:
        confidence = 0.0
    else:
        sector_center_deg = (sector_idx * 45.0) % 360.0
        delta = abs((angle_norm - sector_center_deg + 180.0) % 360.0 - 180.0)
        confidence = max(0.0, 1.0 - delta / 22.5)

    return EmotionLabel(
        primary=primary,
        intensity=intensity,
        name=_INTENSITY_NAMES[primary][intensity],
        confidence=confidence,
    )

emotional_memory.categorize.label_tag(tag: EmotionalTag, dominance: float | None = None) -> EmotionalTag

Return a copy of tag with the emotion_label field populated.

Parameters:

Name Type Description Default
tag EmotionalTag

The emotional tag to label.

required
dominance float | None

Optional dominance value for fear/anger disambiguation. If None and the tag has a mood_snapshot, the mood's dominance is used automatically.

None

Returns:

Type Description
EmotionalTag

A new frozen :class:~emotional_memory.models.EmotionalTag with

EmotionalTag

emotion_label set.

Source code in src/emotional_memory/categorize.py
def label_tag(
    tag: EmotionalTag,
    dominance: float | None = None,
) -> EmotionalTag:
    """Return a copy of *tag* with the ``emotion_label`` field populated.

    Args:
        tag:       The emotional tag to label.
        dominance: Optional dominance value for fear/anger disambiguation.
                   If ``None`` and the tag has a ``mood_snapshot``, the
                   mood's dominance is used automatically.

    Returns:
        A new frozen :class:`~emotional_memory.models.EmotionalTag` with
        ``emotion_label`` set.
    """
    # Auto-extract dominance from mood snapshot when not provided
    if dominance is None and tag.mood_snapshot is not None:
        dominance = tag.mood_snapshot.dominance
    label = categorize_affect(tag.core_affect, dominance=dominance)
    return tag.model_copy(update={"emotion_label": label})