Source code for eko.matchings

r"""Holds the classes that define the |FNS|."""

import logging
from dataclasses import dataclass
from typing import List, Union

import numba as nb
import numpy as np

from .constants import MTAU
from .io.types import EvolutionPoint as EPoint
from .io.types import FlavorIndex, FlavorsNumber, SquaredScale
from .quantities.heavy_quarks import MatchingScales

logger = logging.getLogger(__name__)


[docs] @dataclass(frozen=True) class Segment: """Oriented path in the threshold landscape.""" origin: SquaredScale """Starting point.""" target: SquaredScale """Final point.""" nf: FlavorsNumber """Number of active flavors.""" @property def is_downward(self) -> bool: """Return True if ``origin`` is bigger than ``target``.""" return self.origin > self.target def __str__(self): """Textual representation, mainly for logging purpose.""" return f"Segment({self.origin} -> {self.target}, nf={self.nf})"
Path = List[Segment]
[docs] @dataclass(frozen=True) class Matching: """Matching between two different segments. The meaning of the flavor index `hq` is the PID of the corresponding heavy quark. """ scale: SquaredScale hq: FlavorIndex inverse: bool
MatchedPath = List[Union[Segment, Matching]]
[docs] class Atlas: r"""Holds information about the matching scales. These scales are the :math:`Q^2` has to pass in order to get there from a given :math:`Q^2_{ref}`. """ def __init__(self, matching_scales: MatchingScales, origin: EPoint): """Create basic atlas.""" self.walls = [0] + matching_scales + [np.inf] self.origin = self.normalize(origin) logger.info(str(self))
[docs] def normalize(self, target: EPoint) -> EPoint: """Fill number of flavors if needed.""" if target[1] is not None: return target return (target[0], nf_default(target[0], self))
def __str__(self): """Textual representation, mainly for logging purpose.""" walls = " - ".join([f"{w:.2e}" for w in self.walls]) return f"Atlas [{walls}], ref={self.origin[0]} @ {self.origin[1]}"
[docs] @classmethod def ffns(cls, nf: int, mu2: SquaredScale): """Create a |FFNS| setup. The function creates simply sufficient thresholds at ``0`` (in the beginning), since the number of flavors is determined by counting from below. The origin is set with that number of flavors. """ matching_scales = MatchingScales([0] * (nf - 3) + [np.inf] * (6 - nf)) origin = (mu2, nf) return cls(matching_scales, origin)
[docs] def path(self, target: EPoint) -> Path: """Determine the path to the target evolution point. Essentially, the path is always monotonic in the number of flavors, increasing or decreasing the active flavors by one unit every time a matching happens at the suitable scale. Examples -------- Since this can result in a counter-intuitive behavior, let's walk through some examples. Starting with the intuitive one: >>> Atlas([10, 20, 30], (5, 3)).path((25, 5)) [Segment(5, 10, 3), Segment(10, 20, 4), Segment(20, 25, 5)] If the number of flavor has been reached, it will continue walking without matchin again. >>> Atlas([10, 20, 30], (5, 3)).path((25, 4)) [Segment(5, 10, 3), Segment(10, 25, 4)] It is irrelevant the scale you start from, to step from 3 to 4 you have to cross the charm matching scale, whether this means walking upward or downward. >>> Atlas([10, 20, 30], (15, 3)).path((25, 5)) [Segment(15, 10, 3), Segment(10, 20, 4), Segment(20, 25, 5)] An actual backward evolution is defined by lowering the number of flavors going from origin to target. >>> Atlas([10, 20, 30], (25, 5)).path((5, 3)) [Segment(25, 20, 5), Segment(20, 10, 4), Segment(10, 5, 3)] But the only difference is in the matching between two segments, since a single segment is always happening in a fixed number of flavors, and it is completely analogue for upward or downward evolution. Note ---- Since the only task required to determine a path is interleaving the correct matching scales, this is done by slicing the walls. """ mu20, nf0 = self.origin mu2f, nff = self.normalize(target) # determine direction and python slice modifier rc, shift = (-1, -3) if nff < nf0 else (1, -2) # join all necessary points in one list boundaries = [mu20] + self.walls[nf0 + shift : nff + shift : rc] + [mu2f] return [ Segment(boundaries[i], mu2, nf0 + i * rc) for i, mu2 in enumerate(boundaries[1:]) ]
[docs] def matched_path(self, target: EPoint) -> MatchedPath: """Determine the path to the target, including matchings. In practice, just a wrapper around :meth:`path` adding the intermediate matchings. """ path = self.path(target) inverse = is_downward_path(path) prev = path[0] matched: MatchedPath = [prev] for seg in path[1:]: matching = Matching(prev.target, max(prev.nf, seg.nf), inverse) matched.append(matching) matched.append(seg) prev = seg return matched
[docs] def nf_default(mu2: SquaredScale, atlas: Atlas) -> FlavorsNumber: r"""Determine the number of active flavors in the *default flow*. Default flow is defined by the natural sorting of the matching scales: .. math:: \mu_c < \mu_b < \mu_t So, the flow is defined starting with 3 flavors below the charm matching, and increasing by one every time a matching scale is passed while increasing the scale. """ ref_idx = np.digitize(mu2, atlas.walls) return int(2 + ref_idx)
[docs] def is_downward_path(path: Path) -> bool: r"""Determine if a path is downward. Criterias are: - in the number of active flavors when the path list contains more than one :class:`Segment`, note this can be different from each :attr:`Segment.is_downward` - in :math:`\mu^2`, when just one single :class:`Segment` is given """ if len(path) == 1: return path[0].is_downward return path[1].nf < path[0].nf
[docs] def flavor_shift(is_downward: bool) -> int: """Determine the shift to number of light flavors.""" return 4 if is_downward else 3
[docs] @nb.njit(cache=True) def lepton_number(q2): """Compute the number of leptons. Note: muons and electrons are always massless as for up, down and strange. Parameters ---------- q2 : float scale Returns ------- int : Number of leptons """ return 3 if q2 > MTAU**2 else 2