# Field view frontend working document
###### tags: `frontend`
[TOC]
## Useful links:
- [Array API terms](https://data-apis.org/array-api/latest/purpose_and_scope.html#terms-and-definitions)
- [Xarray terminology](http://xarray.pydata.org/en/stable/user-guide/terminology.html)
- [mypy playground](https://mypy-play.net)
- [Python PEPs Index](https://www.python.org/dev/peps/)
## Action points
__Week 1__
- [X] Preliminary list of domain scientist candidates for feedback
- Jeremy
- Christian
- David Leutwyler
- Anurag?
- Stefano?
- Oli?
- Sascha? (MCH)
- Stephanie? (MCH)
- [X] Ideas to define the evaluation/feedback process with users
- Are all use cases covered? Are there remaining issues / dealbreakers?
- What can be improved? (Likes, Wishes, What if?)
- Send example + mock API / syntax documentation before first session
- 3rd week: First session - Streamlined explanation of syntax, show example (and gather feedback)
- Let them play with the examples
- 4th week: Second session starts with feedback from them
- 5th, 6th week implement feedback, document decisions to implement now, later or not at all
- [x] Skeleton for the frontend specification (API design)
__Week 2__
## Feedback
*Status*
On hold, to be continued after MeteoSwiss workshop.
### Template for asking participants
### Scratchpad
#### First email / slack (propspective participants)
- [X] ask if willing to give feedback
- [X] give impression of time requirement
Hi XXX
The GT4Py Team is working on the next iteration of GT4Py adding support for non-Cartesian grids.
We would like to get some early stage feedback from users with experience in the domain.
Our idea is to schedule two meetings. In the first one we would like to present the draft interface and in a follow-up meeting during the following week gather your feedback and opinions. In between you will need to take some more time to study the examples and make up your opinions.
Total time required: 3.5 - 5.5 h over a week.
If you would be willing, please let us know your availabilities in the week of the Xth to the Yth for a 1 hour meeting.
Best,
The GT4Py Team
#### Second email / slack (interested participants)
- [ ] detailed schedule when & what
## Quick start
```python=
@gt.local_operator
def average(inp: Field[(I, J), DT], *, position: Position[I, J]):
i, j = position
return 1/5*(inp[i, j] + inp[i-1, j] + inp[i+1, j] + inp[i, j-1] + inp[i, j+1])
@gt.field_operator
def double_average(inp: Field[(I, J), DT]):
return average[inp.domain](average[inp.domain](inp))
```
## API
### Dimension
```python=
@dataclass(frozen=True)
class Dimension:
name: str
```
### Index
```python=
@dataclass(frozen=True)
class Index:
dimension: str
value: int
```
*Usage*:
```python=
idx = Index("I", 0)
```
### Index Range
```python=
@dataclass(frozen=True)
class IndexRange:
dimension: str
start: int
stop: int
```
*Usage*:
```python=
index_range = IndexRange("I", 0, 10)
```
### Position
```python=
@dataclass(frozen=True)
class Position:
indices: Tuple[Index, ...]
```
*Usage*:
```python=
pos = Position(Index("I", 0), Index("J", 1))
```
### Domain
```python=
@dataclass(frozen=True)
class Domain:
ranges: List[IndexRange]
```
*Usage*:
```python=
domain_1d = Domain(IndexRange("A", 0, 5))
assert Position(Index("A", 0)) in domain
assert Position(Index("A", 4)) in domain
assert Position(Index("A", 5)) not in domain
domain_2d = Domain(IndexRange("A", -3, 3), IndexRange("B", -3, 3))
a_intersect_b = a & b # intersection
a_union_b = a | b # union
# syntactic sugar
domain_3d = Domain(("A", -3, 3), ("B", -2, 2), ("C", -3, 3))
assert ("A", 4) not in domain_3d
```
### Field
```python=
@dataclass(frozen=True)
class Field:
domain: Domain
# no image needed in public api
def __getitem__(self, pos: Position):
...
@dataclass
class MaterializedField(Field):
image: NDArray
@dataclass
class FunctionalField(Field):
func: Callable[[Position], Any]
def FieldFactory(...): ...
```
*Usage*:
Fields can be constructed by passing a domain and either a function or an image to the factory. The return value is the corresponding Field subclass.
```python=
domain = Domain(IndexRange("A", -3, 3), IndexRange("B", -3, 3)
field = FieldFactory(domain, function=lambda pos: pos)
other_field = FieldFactory(domain, image=some_ndarray)
test_position = Position(Index("A", 0), Index("B", 0))
assert field[test_position] == test_position
```
#### Arithmetic
`Field`s support the regular (element-wise) arithmetic operations `+`, `-`, `*`, `/`. The resulting field's domain is the intersection of the operand's domains.
```python=
field_ab = field_a + field_b
field_ab.domain == (field_a.domain & field_b.domain)
field_ab[pos] == field_a[pos] + field_b[pos]
```
#### Remap
```python=
cell_domain = ...
field_ij: Field[(I, J), DT] = FieldFactory(cell_domain, function=lambda pos: pos)
# relocate
field_sij: Field[(I+1/2), J] = field_vertex(I+1/2)
field_sij.domain == cell_domain.translate(I, 1/2) # WIP
# shift
field_sij: Field[(I+1/2), J] = field_vertex(I+4.5)
field_sij.domain == cell_domain.translate(I, 4.5) # WIP
# laplacian
lap = -4*field_a + field_a(I-1) + field_a(I+1) + field_a(J-1) + field_a(J+1)
```
#### Local operator
*Usage*:
```python=
@gt.local_operator
def laplacian(inp: Field[(I, J), DT], *, position: Position[I, J]) -> Field:
i, j = position
return -4*inp[i, j]+inp[i-1, j]+inp[i+1, j]+inp[i, j-1]+inp[i, j+1]
```
*Lifting*
```python=
field_op: FieldOperator = local_op[domain]
```
#### Field operator composition
We have already seen examples of field operators in the form of arithmetic between fields or lifted local operators. In many cases it is useful to compose field operators into new field operators, e.g. a laplacian can be composed of a divergence and a nabla operator.
```python=
@gt.field_operator
def field_op(inp: Field) -> Field:
tmp: Field = field_op1(inp)
return field_op2(tmp)
```
## Concepts
### Dimension
A *dimension* is a just a tag or type for [indices](#index).
### Local dimension
A *local dimension* is a just a tag or type for [local indices](#index).
### Index
An index $i_I$ is a signed integer $i \in \mathbb{Z}$ tagged with a [dimension](#Dimension) $I$. The set of all indices of a particular dimension is called an axis.
### Local index
A local index $\hat{i}_{\hat I}$ is an unsigned integer tagged with a [local dimension](#Local-dimension).
### Index range
An *index range* $[a_I, b_I[$ is the set of all indices between two indices $a_I$, $b_I$ of same dimension $I$, i.e. $\{a_I, \dots, b_I-1\}$ ([wikipedia](https://en.wikipedia.org/wiki/Interval_(mathematics)#Integer_intervals)). As such an *index range* is a subset (without "holes") of an axis. Note that $b_I$ is not an element of the index range.
### Position
A *position* is a tuple of indices of different dimension uniquely identifying an element, e.g. $(0_I, -1_J, 2_K)$.
### Domain
A *domain* is a cartesian product of index ranges of different dimension.
### Field
A *field* is a function $f: D \to V, pos \mapsto v$, with $D$ a domain and $V$ a set of values.
__Remap__
The $remap$ operation maps a field $f$ on elements of one type to a new field $g$ on elements of another type such that the resulting field maps elements
$remap(f, c) = g$
$g(pos) = f(c(pos, 0))$ (Cartesian)
$g(pos) = pos \mapsto (\hat{i} \mapsto f(c(pos, \hat{i})))$ (Unstructured)
### Connectivity
A *connectivity* is a function $c_{\hat{dim}}: pos \mapsto (nb\_pos_1, \ldots, nb\_pos_n)$, mapping a position $pos$ to a set of incident positions $nb\_pos_i$.
### Local connectivity
A local connectivity $\hat{c}: \hat{i}_\hat{I} \mapsto i_I$ is mapping from a local index $\hat{i}_\hat{I}$ to a (global) index $i_I$.
### Local operator
A *local operator* is a function $\hat{op}: (pos, f_1, \ldots, f_n) \mapsto v$, where $pos$ is a position, element of the domain $D$, $f_1, \ldots, f_n$ a set of (input) fields, and $v \in V$ a value. Local operators are used to express what is commonly known as a stencil operation: Given a position we calculate a new value based on the value of the input fields in its neighborhood.
### Field operator
A *field operator* is (higher-order) function $op: (f_1, \ldots, f_n) \mapsto f_{\text{out}}$, where $f_1, \ldots, f_n, f_{out}$ are a set fields. In other words a field operator is a function mapping fields to a field.
## Advanced examples
## Scratchpad
__Dimension__
A *dimension* is a just a tag or type for [indices](#index)
```python=
# Dimension as (empty?) subclasses of "Dimension"
class Dimension:
# Optional:
# def __init_subclass__(cls):
# #assert cls.__dict__ is empty
# Dimension.__cache__[cls.__module__. + cls.__name__] = cls
# def __init__(self):
# # assert False, "Do NOT instantiate this class"
...
I(3) == Index(I, 3)
I[3:9] == IndexRange(I, 3, 9)
class I(Dimension):
...
# Optional for the user
# discouraged for manual use due to name duplication
# I = new_type("I", (Dimension,))
# I: Dimension = Dimension("I") # Dimensions as instances
# class Dimension(Generic[T]):
# name: T
# def __new__():
# ...
# def index(self, value):
# ...
# I = Dimension("I")
# I: Type[Dimension] = new_type(...) # Dimensions as types
# I: str = "I" # Dimensions as strings
```
__Index__
An index is an integer in $\mathbb{Z}$ tagged with a [dimension](#Dimension), i.e. an element of an axis.
```python=
# Indices as instances
DimT = TypeVar("DimT", bound=Type[Dimension])
class Index(Generic[DimT]):
dimension: DimT
value: int
# Probably instances should be valid indices
# def __index__(self) - > int: ...
i: Index[I] = Index(I, 0)
# dimension may have factory
# i2: Index[I] = I.index(1)
# nested index class under dimension class:
# discarded because indices all behave the
# same independently of dimension -> duplication
```
TODO: example math syntax
__Index range__
An *index range* is the set of all indices between two indices of same dimension, e.g $\{1_I, 2_I, 3_I\}$, $\{-1_I, 0_I, 1_I\}$ ([wikipedia](https://en.wikipedia.org/wiki/Interval_(mathematics)#Integer_intervals)). As such an *index range* is a subset of an axis.
```python=
class IndexRange(Generic[DimT]):
dimension: DimT
start: int # Included
stop: int # Not included
# bounds: [start, stop)
# TODO: define arithmetic operators for ranges
# Iterable[int]
# def __iter__(self): ....
# Indexable
# def __getitem__(self, index: int) -> Index: ....
i_range: IndexRange[I] = IndexRange(I, (-2, 10))
```
__Domain__
A *domain* is a cartesian product of index ranges of different dimension.
_We may need to revisit this name in the future (other alternatives: IndexSpace, Space, Region._
```python=
class Domain:
ranges: Tuple[IndexRange, ...]
def __contains__(self, item): ...
# Iterable[Position]
# Indexable:
# def __getitem__(self, Tuple[int, ...]) -> Position:
Domain((I, (-2, 10)), J=10, K=10)
domain = Domain(i_range, j_range)
domain = i_range * j_range
domain.add_subdomain("interior", domain[1:-1, 1:-1]) # what about passing other_domain[1:-1, 1:-1]. assert is subset?
domain["interior"] == domain[1:-1, 1:-1]
domain[1:-1, 1:-1] = "interior"
# subdomains
```
TODO: Domain should be indexable with (absolute) Indices and relative indices
__Position__
An element of a domain, i.e. a tuple of indices.
```python=
Position = Tuple[Index, ...]
class Position:
indices: Tuple[Index, ...]
# optional: def __getitem__(self, item: int | str): ...
Position(Index(I, 10), Index(J, 30))
```
__Field__ (proposal 1: "always with halos")
A field is a datastructure mapping positions to values. As such it can be thought of as a function $f: D \to V, pos \mapsto v$, with $D$ a domain and $V$ a set of values.
----
A *field* is a function $f: D \to V, pos \mapsto v$, with $D$ a domain formed by cartesian product of integer ranges, and $V$ a set of values. In other words a field maps a position to a value.
Operations with fields:
- _map_: given a field $f: D \to V$ and a function $g: V \to W$, $map(g, f) = g \circ f$
- _imap_: given a field $f: D \to V$ and a surjective function $h: A \to B \subseteq D$, $imap(h, f) = f_{|B} \circ h$ # TODO: restrict h to inverse image of D
- _restrict_: given a field $f: D \to V$ and a subdomain $D^\prime$ (i.e. $D^\prime \subset D$), $restrict(f, D^\prime) = f_{|D^\prime}: D^\prime \subset D \to V$ such that $f_{|D^\prime}(pos) = f(pos)$. In other words $f_{|D^\prime}$ is obtained from $f$ by choosing a smaller domain $D^\prime \subset D$.
- _combine_: given two fields $f_1: D_1 \to V$ and $f_2: D_2 \to V$ where $D_1 \cup D_2$ forms a valid domain (i.e., a product set of integer ranges), $combine(f_1, f_2) = f: D_1 \cup D_2 \to V$ such that $f(x) = f_1(x)$ if $x \in D_1$ and $f(x) = f_2(x)$ if $x \in D_2$
- what if $D_1 \cap D_2 \neq \{\}$ ?
- _unrestrict_: the inverse of restrict, that is, $unrestrict(f_{|D^\prime}) = f: D \to V$.
----
```python=
class Field:
domain: Domain
image: npt.NDArray[DT]
data: npt.NDArray[DT]
origin: Tuple[int, ...] # might require dimensions info
def __getitem__(self, pos: Position):
# check pos in self.domain
...
def __op__(self, other) -> Field:
assert self.domain == other.domain
# pseudocode:
Field(
domain=self.domain,
image=(op(image[position], other.image[position])
for position in domain)
)
def remap(self, conn: Callable[[SourcePos], TargetPos])
return Field(map(conn, self.domain), data=self.data, origin=self.origin)
def __shift__(self, dimension, offset) -> Field:
return Field(
domain=self.domain,
data=self.data,
origin=shift(self.origin, dimension, offset),
# origin = shift_origin(self.origin, dimension, offset)
# TODO: workout if origin needs to hold more info than a Tuple[int, ...]
)
# Shift and arithmethic operation
result = field_a(I-1) * field_a(I+1)
# Restrict domain operations (with or without halos)
sub_field = select(field_a, subdomain) => field_a[subdomain]
restricted_field = restrict(field_a, subdomain) => field_a[subdomain].drop_halo()
# Combine fields with adjacent domains
field_a = Field((I[0:10], J[0:10]), ...)
field_bottom_left = Field((I[-2:0], J[-2:0]), ...)
merged_field = combine(field_a, field_bottom_left)
# TODO: type annotations interface for Fields
T = TypeVar('T')
IJFIeld = Annotated[Field[T], (I, J)]
IJField[VType[float]]
# -- should be supported --
I = Dimension(...)
D = Domain(...)
A = Field(D, some_buffer)
B = Field(D, some_other_buffer)
C = A + B
D = A + shift(B, I + 1)
assert shift(B, ...).domain == B.domain # always
```
__Field__ (proposal 2: no halos)
A *field* is a function $f: D \to V, pos \mapsto v$, with $D$ a domain and $V$ a set of values. In other words a field maps a position to a value.
```python=
class Field:
domain: Domain
image: npt.NDArray[DT]
def __getitem__(self, position_or_subdomain):
if isinstance(position_or_subdomain, Position):
position = position_or_subdomain
return self.image[position-self.domain.origin]
# (ricoh) The domain concept does not allow inference on what domain.origin would be
# nor does the accompanying example
elif isinstance(position_or_subdomain, Domain):
subdomain = position_or_subdomain
slices = (slice(r2.start - r1.start, r2.stop-r1.stop) for r1, r2 in zip(self.domain.ranges, subdomain.ranges))
# (ricoh) assuming domain range for dimension I is (0, 4)
# and subdomain range for dimension I is (1, 3)
# then this would return image[-1:1]
# It is hard to match this to the concepts it is supposed to illustrate
return Field(subdomain, self.image[slices])
raise TypeError()
```
__Field operator__
A *field operator* is function $op: (f_1, \ldots, f_n) \mapsto f_{\text{out}}$, where $f_1, \ldots, f_n, f_{out}$ are a set of fields. In other words a field operator is a function mapping fields to a field.
__Restriction__
In agreement with the usual concept of a [restriction](https://en.wikipedia.org/wiki/Restriction_(mathematics)) of a function, the restriction of a field $f: D \to V$ to a domain $D^\prime$ is a new field $f_{|D^\prime}: D^\prime \subset D \to V$ such that $f_{|D^\prime}(pos) = f(pos)$. In other words $f_{|D^\prime}$ is obtained from $f$ by choosing a smaller domain $D^\prime \subset D$.
<!--A *transparent view* is a field $f: D \to V$ together with a "backing field" $g: \mathbb{D} \to \mathbb{V}$ such that $\forall pos \in D: \, f(pos)=g(pos)$. As such $D \subset \mathbb{D}$ as well as $V \subset \mathbb{V}$. For sake of simplicity we denote the backing field of $f$ as $backing\_field(f)$.-->
__Field__ (proposal 2: no halos)
A *field* is a field $f: D \to V$ together with a "backing field" $g: \mathbb{D} \to \mathbb{V}$ such that $\forall pos \in D: \, f(pos)=g(pos)$. As such $D \subset \mathbb{D}$ as well as $V \subset \mathbb{V}$. For sake of simplicity we denote the backing field of $f$ as $backing\_field(f)$.
Alternative:
```python=
class FieldOpMixin:
def __op__(self, other):
@local_operator
def my_local_op(a: Field, b: Field):
return op(a[pos], b[pos])
return my_local_op[self.domain](self, other)
class Field(FieldOpMixin):
domain: Domain
image: npt.NDArray[DT]
def __getitem__(self, item):
return self._backing_field(item)
class FieldWithHalo(FieldOpMixin):
domain: Domain
_backing_field: Field
@property
def image(self):
return self._backing_field[self.domain]
def __getitem__(self, item):
return self._backing_field(item)
def __shift__(self, dimension, offset)
return Field(self._backing_field.domain.translate(dimension, offset)).transparent_view(self.domain)
for_diff = combine(inp[0], inp[0:-1]-inp[1:], inp[-1])
# results in a field with a bigger domain than inp:
# 1 + len(inp) - 1 + 1 = len(inp) + 1
```
Difference in user code:
```python=
a = Field(...)
b = Field(...)
a + b[I + 1]
# vs.
a[domain[:-1, ...]] + b[I + 1] # "domain shrinking" inferred for a?
# because b[I+1].domain is 1 smaller in I direction
# so a has to be shrunken accordingly
```