Skip to content
Closed
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
23 changes: 19 additions & 4 deletions docs/about/limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ costs to add.
constraints before the model is built. `piecewise:` is the only one. It costs
as much as a primitive to build, but composes as freely as a macro, because
the rest of the model only sees the variables and constraints it emitted.
- **A declaration section** is a block of declarations of one kind, such as
`variables:` or `given_variables:`. One enters where it says something no
section already says, where a file decides it without data, and where the
typesetter prints it. `given_variables:` entered on all three: nothing else
states that a column belongs to another file, which is what lets a template
load and print on its own.

A request that is none of the three is refused, and the
[table of refusals](#deliberate-non-primitives) records it with what to write
Expand Down Expand Up @@ -182,10 +188,19 @@ a path, so a model assembled in Python is checked exactly as a file is, and
`Spec.to_yaml()` writes the file a reviewer reads. A `dict` may hold only what a
file may hold. There is no Python API that builds models any other way.

`override` is the verb for the layered case, where a framework ships a base
and a project extends it. It lays each patch over the base a field at a time,
so a patch says only what it changes.
[Compose a model from a base and patches](../howto/compose.md) is the recipe.
Two verbs do this, and they answer different questions. `merge` composes
peers, so a name two templates declare is a collision and the order they are
given in means nothing. `override` lays patches over a base, which is what a
framework ships and a project extends, a field at a time.
[Compose a model from several files](../howto/compose.md) is the recipe for
both.

A template reads the coupling surface it is written against, and declares that
column under `given_variables:`. So a template loads on its own, and prints as
math on its own, which is what it could not do while a fragment was a file the
loader had to refuse. `merge` folds each given declaration into the one that
introduces it, and a program carries none of them: a build makes every column it
holds.

The verb is built to collide, so every collision the caller did not ask for is
refused. An entry naming some fields of a declaration the base does not have is
Expand Down
152 changes: 98 additions & 54 deletions docs/howto/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,72 +3,106 @@ SPDX-FileCopyrightText: math-spec contributors
SPDX-License-Identifier: CC-BY-4.0
-->

# Compose a model from a base and patches
# Compose a model from several files

Extend a model somebody else wrote, without copying it. The base is an ordinary
model file. A patch is a file that says only what it changes. `override` lays
the patches on the base and hands back one mapping, which
[`to_spec`](../reference/language/reading.md) loads like any file.
Build one model out of files that each say part of it. `merge` composes
**peers** — the templates of a component library, where a name two of them
declare is a collision. `override` lays **patches** over a base — what a
framework ships and a project extends. Both hand back one mapping, which
[`to_spec`](../reference/language/reading.md) loads like any file, and they
compose: `override(merge({…}), {…})`.

1. **Write the base as a model.** Nothing about it says it will be extended.
## A library of templates

```yaml title="base.yaml"
description: Least-cost dispatch of a generator fleet against an hourly load.
1. **Write the coupling surface as a model.** One flow per port, one balance
per bus. Nothing in it knows which components exist.

```yaml title="surface.yaml"
dimensions:
snapshot: { dtype: int }
port: { dtype: str }
bus: { dtype: str }
relations:
port_bus: { key: port, value: bus }
variables:
flow:
dims: [snapshot, port]
description: what a port puts into its bus in a snapshot
constraints:
balance:
dims: [snapshot, bus]
expression: sum(flow, by=port_bus) == 0
```

2. **Write each component template against that surface.** It declares its own
entities, its own math, and one relation into `port`. It names `flow` under
[`given_variables`](../reference/language/declarations.md#given_variables),
because the surface introduces that column and this file only reads it.

```yaml title="generator.yaml"
dimensions:
snapshot: { dtype: int }
port: { dtype: str }
generator: { dtype: str }
relations:
gen_port: { key: generator, value: port }
given_variables:
flow: { dims: [snapshot, port] }
parameters:
load: { dims: [snapshot] }
cost: { dims: [generator] }
capacity: { dims: [generator] }
gen_cost: { dims: [generator] }
gen_p_max: { dims: [generator] }
variables:
dispatch:
dims: [snapshot, generator]
bounds: { lower: 0, upper: capacity }
gen_p: { dims: [snapshot, generator], bounds: { lower: 0, upper: gen_p_max } }
constraints:
power_balance:
dims: [snapshot]
expression: sum(dispatch, over=generator) == load
gen_injects:
dims: [snapshot, generator]
expression: at(flow, by=gen_port) == gen_p
objective:
sense: minimize
expression: sum(dispatch * cost)
expression: sum(gen_p * gen_cost)
```

2. **Write each patch as the change it makes.** A declaration a patch does not
name is left as the base wrote it. A patch is not a model on its own, so
`to_spec` does not read one.
The template loads on its own, and it prints as math on its own. What it
cannot do is lower: a program builds every column it carries, and this file
says the opposite about `flow`.

```yaml title="carbon.yaml"
parameters:
emission_rate: { dims: [generator] }
constraints:
emission_cap:
dims: []
expression: sum(dispatch * emission_rate) <= 100
3. **Merge the templates you need.** Each fragment is given a name, and that
name is what a refusal calls it.

```python
import math_spec as ms

model = ms.merge({'surface': 'surface.yaml', 'generator': 'generator.yaml', 'demand': 'demand.yaml'})
spec = ms.to_spec(model)
```

`merge` folds each given declaration into the one that introduces it, so the
composed model declares `flow` once and carries no `given_variables`. It
lowers and solves like any model.

4. **Add a component type without touching the balance.** A template pins the
flow at its own port rather than adding a term, so `balance` is written once
and stays as it is however many templates are merged. What grows is the
data: which ports exist, and which bus each one sits on.

## A base and its patches

1. **Write the base as a model**, and each patch as the change it makes. A
declaration a patch does not name is left as the base wrote it.

```yaml title="operate.yaml"
variables:
dispatch: { where: "capacity > 0" }
gen_p: { where: "gen_p_max > 0" }
```

`operate.yaml` names one field. The dimensions and the bounds the base gave
`dispatch` stay.

3. **Lay the patches on the base.** Each patch is given a name, and that name
is what a refusal calls it.
2. **Lay the patches on the base.** The patches must write different fields, so
the order they are given in cannot change the model.

```python
import math_spec as ms

model = ms.override('base.yaml', {'carbon': 'carbon.yaml', 'operate': 'operate.yaml'})
spec = ms.to_spec(model)
```

The patches must write different fields, so the order they are given in
cannot change the model.

4. **Remove a declaration with `null`.** A patch that does not mention a
3. **Remove a declaration with `null`.** A patch that does not mention a
declaration leaves it alone, so removal needs a marker of its own.

```yaml title="feasibility.yaml"
Expand All @@ -78,15 +112,8 @@ the patches on the base and hands back one mapping, which
```

The marker is the declaration itself. Deeper down, `null` is a value the
schema already takes: `dispatch: { where: null }` gives that variable no
mask, and leaves the variable in place.

5. **Write the composed model out where the file is what you review.**
`Spec.to_yaml()` is what every other page here diffs.

```python
Path('composed.yaml').write_text(ms.to_spec(model).to_yaml())
```
schema already takes: `gen_p: { where: null }` gives that variable no mask,
and leaves the variable in place.

## What a patch may say

Expand All @@ -98,6 +125,23 @@ the patches on the base and hands back one mapping, which
| a dimension or a relation | it is added, or restated exactly as the base declares it |
| `version`, `description` | the patch's value replaces the base's |

## A name two fragments declare

Fragments own their math, so a name two of them declare is refused, both named:

```text
fragments 'generator' and 'demand' both declare the parameter 'cost'. Two of the same kind of thing are two rows of a dimension rather than two fragments: merge the template once, and let the data carry both. Different math under one spelling is a rename — call one of them something else.
```

## A column read one way and introduced another

What a template states about a column it reads has to agree with the file that
owns it:

```text
fragment 'generator' reads given variable 'flow' as {'dims': ['snapshot', 'generator']}, where 'surface' introduces it as {'dims': ['snapshot', 'port'], 'description': 'what a port puts into its bus in a snapshot'}. A given declaration is what the file expects of a column somebody else owns, so it says the same as the declaration it is folded into, or less.
```

## A partial entry that lands on nothing

An entry naming some fields has to land on a declaration the base has. A
Expand Down Expand Up @@ -125,11 +169,11 @@ lays its patch on the first call's result, so the order is on the page:
model = ms.override(ms.override('base.yaml', {'pathway': 'pathway.yaml'}), {'project': 'project.yaml'})
```

## An axis the base already declares
## An axis the other file already declares

A patch may add a dimension or a relation, and may restate one the base
declares. Changing one under the expressions already written over it is
refused:
A fragment may add a dimension or a relation, and two fragments may declare one
the same way. Saying different things about one is refused, and so is changing
one under the expressions already written over it:

```text
patch 'relabelled' declares the dimension 'snapshot' as {'dtype': 'str'}, where its base declares {'dtype': 'int'}. A patch adjusts the math, not the axes the math is already written over: restate the declaration exactly, leave it out, or give the patch an axis of its own under a name of its own.
Expand Down
50 changes: 50 additions & 0 deletions docs/reference/language/declarations.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,56 @@ equation whether `size` is chosen or given. A pinned variable is still a
variable, so `size * on` is `variable * variable`, and a pinned variable cannot
stand in another variable's `bounds`.

## `given_variables`

A given variable is a column this file reads and another file introduces. It is
what lets a template stand on its own: the file loads, and it prints as math,
without the file that owns the column.

```yaml
dimensions:
snapshot: { dtype: int }
port: { dtype: str }
generator: { dtype: str }
relations:
gen_port: { key: generator, value: port }
given_variables:
flow:
dims: [snapshot, port]
description: what a port puts into its bus
variables:
gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } }
constraints:
gen_injects:
dims: [snapshot, generator]
expression: at(flow, by=gen_port) == gen_p
```

| Field | | |
| ------------- | ------------------------------------------------- | -------------------- |
| `dims` | required. The dimensions the column is indexed by | |
| `domain` | `continuous`, `integer` or `binary` | default `continuous` |
| `description` | free text | default `null` |

There is no `bounds` and no `where`. The file that introduces the column owns
both, and a second spelling here would be a second home for one fact.

An expression reads a given variable as it reads any other, so
`at(flow, by=gen_port)` lands on the generator frame and the dim algebra
checks it at load.

**A file with a `given_variables` block does not lower.** A program builds
every column it carries, and this file says the opposite about one of its own:

```text
this file reads a variable it does not introduce: 'flow'. A program builds every column it carries, so compose the file with the ones that declare them first — to_program(merge({...})). The file loads and prints on its own either way.
```

[`merge`](../../howto/compose.md) folds each given declaration into the
declaration that introduces it, so a composed model carries none of them. The
folded declaration is the introducer's, and what the reader stated has to agree
with it.

## `constraints`

One block is one rule. The name of the block is the name of the constraint, and
Expand Down
31 changes: 16 additions & 15 deletions docs/reference/language/file.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,22 @@ SPDX-License-Identifier: CC-BY-4.0

# File shape

A model file is a YAML mapping with **ten declaration keys**, plus `version`
and `description`. Any subset of the ten is accepted.

| Key | |
| ------------- | ------------------------------------------------------------------------------------------------------------------- |
| `dimensions` | the axes ([dimensions](dimensions.md)) |
| `relations` | named relations between dimensions ([relations](dimensions.md#relations)) |
| `parameters` | the data the model expects ([declarations](declarations.md)) |
| `variables` | what the solver decides |
| `constraints` | the rules those decisions obey |
| `objective` | what is minimised or maximised |
| `expressions` | named quantities, reusable in the math and readable after a solve ([expressions](expressions.md#named-expressions)) |
| `macros` | templates that take arguments ([macros](expressions.md#macros)) |
| `piecewise` | piecewise-linear curves ([piecewise](piecewise.md)) |
| `sos` | special-ordered sets ([sos](piecewise.md#sos)) |
A model file is a YAML mapping with **eleven declaration keys**, plus
`version` and `description`. Any subset of the eleven is accepted.

| Key | |
| ----------------- | ------------------------------------------------------------------------------------------------------------------- |
| `dimensions` | the axes ([dimensions](dimensions.md)) |
| `relations` | named relations between dimensions ([relations](dimensions.md#relations)) |
| `parameters` | the data the model expects ([declarations](declarations.md)) |
| `variables` | what the solver decides |
| `given_variables` | columns this file reads and another introduces ([given variables](declarations.md#given_variables)) |
| `constraints` | the rules those decisions obey |
| `objective` | what is minimised or maximised |
| `expressions` | named quantities, reusable in the math and readable after a solve ([expressions](expressions.md#named-expressions)) |
| `macros` | templates that take arguments ([macros](expressions.md#macros)) |
| `piecewise` | piecewise-linear curves ([piecewise](piecewise.md)) |
| `sos` | special-ordered sets ([sos](piecewise.md#sos)) |

A file with no `objective` is a **feasibility problem**: it asks whether the
constraints can all be met. It loads and solves like any other model, and the
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/language/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ message that names the fix. These ten rules are what it checks.

| # | Rule | |
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| 1 | A file has ten declaration keys, plus `version` and `description`. A key the schema does not know is refused, with the nearest valid key named: `boundz` → `bounds`. | [File shape](file.md) |
| 1 | A file has eleven declaration keys, plus `version` and `description`. A key the schema does not know is refused, with the nearest valid key named: `boundz` → `bounds`. | [File shape](file.md) |
| 2 | Everything that can be checked without data is checked when the file loads. | [Errors](errors.md) |
| 3 | Every name is declared once. A parameter and a dimension both called `snapshot` is refused, and the message names both lines. | [Names](expressions.md#name-resolution) |
| 4 | Where a name may stand depends on what it is. A dimension may follow `over=` or `along=`, and may not be multiplied: `dispatch * snapshot` is refused, because `snapshot` is an axis and not a column of numbers. | [Names](expressions.md#name-resolution) |
Expand Down
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ nav:
- Check a model without data: howto/check.md
- Print a model as math: howto/print.md
- State a rule that differs by regime: howto/regimes.md
- Compose a model from a base and patches: howto/compose.md
- Compose a model from several files: howto/compose.md
- Reference:
- Language:
- reference/language/index.md
Expand Down
Loading