Shapeshift in Python¶
Writing .shm by hand works until the shapes come from somewhere else:
a spreadsheet of points, a specification, another model. Then it is easier
to generate them.
shifty.experimental.shapeshift builds Shapeshift modules from Python
values. It writes them as .shm text or as SHACL Turtle.
from datetime import date
from shifty.experimental import shapeshift as ss
from shifty.experimental.shapeshift import Class, Path, Shape
BRICK = ss.Namespace("brick", "https://brickschema.org/schema/Brick#")
EX = ss.Namespace("ex", "https://example.org/building#")
m = ss.Module("vav", base="urn:example/vav/")
m.points = Path(BRICK.hasPoint)
@m.template
def with_point(cls: Class, n: int = 1):
"""At least `n` points of class `cls`."""
return m.points.at_least(n, cls)
m.vav = Shape(
BRICK.VAV,
m.points.required(flow=BRICK.Air_Flow_Sensor),
m.damper | m.damper_command,
temp=with_point(BRICK.Zone_Air_Temperature_Sensor),
fed=BRICK.isFedBy.exactly(1).each(BRICK.AHU | BRICK.RTU),
commissioned=EX.commissioned.optional().each(
ss.datatype("date"), ss.value_range(date(2000, 1, 1))
),
for_every=BRICK.VAV,
doc="A VAV box.",
)
m.damper = BRICK.hasPart.at_least(1, BRICK.Damper)
m.damper_command = m.points.required(BRICK.Damper_Position_Command)
print(m)
base <urn:example/vav/>
prefix brick: <https://brickschema.org/schema/Brick#>
prefix ex: <https://example.org/building#>
path points = brick:hasPoint
doc "At least `n` points of class `cls`."
shape with-point(cls, n = 1) { points at least n is a cls }
doc "A VAV box."
shape vav for every brick:VAV {
is a brick:VAV
points required { flow: brick:Air_Flow_Sensor }
any of { damper } { damper-command }
temp: with-point(brick:Zone_Air_Temperature_Sensor)
fed: brick:isFedBy exactly 1, each a brick:AHU or brick:RTU
commissioned: ex:commissioned optional, each date, each from 2000-01-01
}
shape damper { brick:hasPart at least 1 is a brick:Damper }
shape damper-command { points required { brick:Damper_Position_Command } }
The output is formatted the same way as shifty shape fmt.
Names¶
Assigning to a module defines a name, and reading one refers to it.
m.zone_temperature = ...defineszone-temperature. Underscores become-.m["zone_temp"] = ...keeps the name as written. Use it for names from data, or names that clash withModulemethods (check,source,write).m.SAT_AVGlooks up the name as written first, then its-form.A name you read before defining it is a reference, resolved when the module is written. So you can use a shape before its definition. An unknown name fails then, with a suggestion.
Assigned values become shapes, except paths:
Path(...)or an expression likeBRICK.feeds.plus.A bare term could be either, so assigning one is an error. Write
Path(BRICK.hasPoint)orShape(BRICK.Point).Keywords (
a,is,value,length, …) can’t name definitions or parameters. They can be labels:Shape(value=...)isvalue: ....
Shapes¶
Shape(*lines, **labelled) holds when all its lines hold. Keyword
arguments are named lines (temp: ...), exported as named property
shapes.
&, |, and ~ mean and, or, and not. On terms, the meaning
depends on where the result goes:
Where a path goes, they combine paths.
BRICK.feeds | BRICK.servesisbrick:feeds or brick:serves.Where a line or test goes, they combine classes.
points.each(BRICK.A | BRICK.B)accepts either class.A template argument is read as a path. For either class, pass
any_of(BRICK.A, BRICK.B).
Python |
|
|---|---|
|
|
a shape or template call alone on a line |
composes it: |
|
both: the same as |
|
|
|
|
|
frames; a keyword argument is a branch of one named line |
|
|
|
|
A Shape that no module names is written inline, as { ... }.
Only a named shape takes for_every=, targets=, doc=,
comment=, severity=, message=, or closed=True
(only these properties).
Paths and fields¶
Where a path goes, a term is a property. Paths combine with:
/for a sequence,|foror, and~for^..plus,.star, and.maybefor repetition.
SELF is the empty path. A path with steps, like
BRICK.feeds.plus / BRICK.hasPart, is only ever a path.
A path’s methods make fields:
Python |
|
|---|---|
|
|
|
the count |
|
|
|
|
|
comparisons |
Field methods chain: p.required().each(T). Annotations return a copy:
.doc(), .severity(), .message(), .comment().
Pass rows to a count method to make a group:
points.required(sat=BRICK.Supply_Air_Temperature_Sensor, flow=BRICK.Air_Flow_Sensor)
points.has(rat=ss.row(BRICK.Return_Air_Temperature_Sensor, "optional", doc="if any"))
parts.required(room=BRICK.Room & points.required(BRICK.Open_Close_Status))
hasuses the default count: exactly one per row.row(item, count, doc=, comment=)gives a row its own count:"required","optional",n, or(min, max), withNonefor an open end.docgoes on the line before the row, andcommentat the end of it.C & linesas a row isC { lines }.
Counts come out in the language’s spelling: p.at_least(1) is
p required, and p.between(0, 1) is p optional.
Tests¶
Where a test goes (each, at_least(n, ...)), Python values map to
tests by type:
A term is a class:
a C.BRICK.A | BRICK.Bisa brick:A or brick:B.A shape or template call is a shape:
matches S.A
str,int,float,bool,datetime.date,datetime.datetime, ordatetime.timeis that value:one of v.lit(s, lang)is a tagged string.
The other tests are functions:
one_of(...)datatype("string")ordatatype(term)node_kind("iri")length(n)orlength(min=, max=)value_range(lo, hi), withabove=andbelow=for excluded bounds.value_range(date(2024, 1, 1), below=date(2025, 1, 1))isfrom 2024-01-01 below 2025-01-01.pattern(regex, flags)language(*tags)
Templates¶
A Python function is a natural template: its parameters are the template’s.
@m.template turns one into a template. The function runs once, when the
module is written, with a placeholder for each parameter. It returns the
template’s lines.
The template takes the function’s name, with
-for_, unless you passname=. The docstring becomes itsdoc.Python defaults become the template’s defaults.
P.clsrefers to an earlier parameter:room_cls: Class = P.cls.A parameter annotated
Classis a class, and alone on a line it isis cls. An unannotated parameter is a shape.Calls are checked against the signature, at the call or, if the template isn’t defined yet, when the module is written.
@m.path_template does the same for paths:
fed(p=BRICK.hasPoint): return BRICK.feeds.plus / p.
Modules¶
Module(name, base=, comment=) writes to <name>.shm.
Prefixes and imports are declared for you, from what the module uses.
m.uses(other, alias="o")importsotherunder an alias, so its names are writteno.name.usesalso sets the import order.m.uses("https://brickschema.org/schema/1.4/Brick")imports RDF.checkthen verifies the classes you use against it.Modules in different Python files share definitions through Python imports.
Output¶
Call |
Result |
|---|---|
|
|
|
the |
|
diagnostics, as |
|
SHACL Turtle, as a string |
|
the Turtle, written to |
|
|
checkreturnsDiagnosticobjects.str(d)prints one as the CLI does.source,export, andwrite_shaclraiseShapeshiftErrorwith the diagnostics when the module doesn’t build.core=Truekeeps to SHACL Core.
A library module often has no targets, so validating with it would check
nothing and always pass. validate raises ValueError instead. Pass
targets to say which nodes to check:
conforms, report, text = vav.validate(
model, targets={vav.vav_cooling_only: BRICK.VAV}
)
A target is a class, a target such as ss.subjects_of(p), or a list of
these. To keep targets for later, write them into a module:
check.every_vav = Shape(vav.vav_cooling_only, for_every=BRICK.VAV).
Larger examples¶
examples/shapeshift/g36/ in the repository has ASHRAE Guideline 36,
written in Python. generate.py writes the .shm files:
terminal_units.py: sections 4.1 to 4.5.air_handlers.py: sections 4.6 and 4.8.afdd.py: the fault conditions of section 5, one shape each.brick.py,bacnet.py, andcomponents.py: the modules the others import, including the rule that every point states its unit and its BACnet object.