9. From your own program¶
Everything the REPL and the editors do is available to a program. There are five ways to reach the
engine, and all of them go through the same implementation, so a model reads the same whichever
one you use. The Go API calls the engine in the process that imports it; the Python,
Node/TypeScript, Java and Rust clients call the sysml-grpc service, which each of them
starts and stops on its own.
| Language | Package | Reaches the engine by | Reference |
|---|---|---|---|
| Go | github.com/Open-MBEE/OpenSysML/client/opensysml |
in process, or Connect to a service | Go packages |
| Python | opensysml |
gRPC, to a private child service or a named one | Python API |
| Node/TypeScript | @opensysml/client |
Connect, from Node or from a browser page | Node API |
| Java | org.openmbee:opensysml-client |
Connect, over the JDK's own HTTP client | Java API |
| Rust | opensysml |
Connect, blocking, with no async runtime | Rust API |
They do not all cover the same ground. Go and Python expose every RPC the service offers. Node, Java and Rust cover a smaller v1 surface (parse, look up a symbol, evaluate, instantiate), and of those three only Node has an escape hatch to the rest, through the generated Connect client it exposes. Only Python and Go are published so far. Client libraries lays out what each covers and how to choose; the troubleshooting chapter covers runs that stop short.
The same task, five ways¶
One model, one task, in each language: parse a file, evaluate an attribute against an object, look up a definition, instantiate it. Pick your language in the tabs; the sections after them go into what only that client has.
// vehicle.sysml
package Demo {
part def Vehicle {
attribute mass = 1500.0;
}
part sedan : Vehicle {
attribute :>> mass = 1800.0;
}
}
Install it¶
The four service clients each need a sysml-grpc binary to start, and they share the cache they
find one in — ~/.opensysml/bin/sysml-grpc, then $PATH, with a release downloaded into that
cache when $OPENSYSML_GRPC_VERSION asks for one. An explicit path comes first, from a variable
that differs by client: $OPENSYSML_BINARY for Python and Node, $OPENSYSML_GRPC_BINARY for Java
and Rust. Getting the service binary gives the five ways to provide
one. The Go API needs none: it is the engine.
Parse, evaluate, look up, instantiate¶
client, err := opensysml.New()
if err != nil { log.Fatal(err) }
defer client.Close()
model, err := client.ParseFile(ctx, "vehicle.sysml")
mass, err := client.Evaluate(ctx, model, "mass", opensysml.WithSubject("Demo::sedan"))
vehicle, err := client.LookupSymbol(ctx, model, "Demo::Vehicle")
built, err := client.Instantiate(ctx, model, "Demo::Vehicle")
fmt.Println(mass) // 1800
fmt.Println(vehicle.Kind, vehicle.ID) // partDef Demo::Vehicle
fmt.Println(built.Root.TypeSymbolID) // Demo::Vehicle
import opensysml
model = opensysml.load("vehicle.sysml")
print(model.eval("mass", subject="Demo::sedan")) # 1800.0
vehicle = model.get("Demo::Vehicle")
print(vehicle.kind, vehicle.id) # partDef Demo::Vehicle
built = model.instantiate("Demo::Vehicle")
print(built.type_symbol_id, built.mass) # Demo::Vehicle 1500.0
import { load } from "@opensysml/client";
await using model = await load("vehicle.sysml");
await model.eval("mass", { subject: "Demo::sedan" }); // { kind: "real", value: 1800 }
const vehicle = await model.symbol("Demo::Vehicle");
vehicle.kind; vehicle.id; // partDef, Demo::Vehicle
const built = await model.instantiate("Demo::Vehicle");
built.root.typeId; // Demo::Vehicle
built.get("mass"); // { kind: "single", value: … }
try (Connection connection = Connection.open()) {
Model model = connection.load(Path.of("vehicle.sysml"));
Value mass = model.evalWithSubject("mass", "Demo::sedan"); // RealValue[value=1800.0]
Symbol vehicle = model.symbol("Demo::Vehicle"); // partDef, Demo::Vehicle
Instantiation built = model.instantiate("Demo::Vehicle");
System.out.println(built.root().typeSymbolId()); // Demo::Vehicle
}
use opensysml::{load, EvalOptions};
let model = load("vehicle.sysml")?;
let subject = EvalOptions { subject: Some("Demo::sedan".into()), ..Default::default() };
let mass = model.evaluate("mass", &subject)?; // Real(1800.0)
let vehicle = model.symbol("Demo::Vehicle")?; // partDef, Demo::Vehicle
let built = model.instantiate("Demo::Vehicle")?;
println!("{}", built.instance.type_symbol_id()); // Demo::Vehicle
Names differ, answers do not: one engine parses the file and one runtime computes mass, so
1800.0 is the same number arrived at the same way whichever tab you read. Broken source is not a
failed call in any of them — it parses, and the errors arrive as the model's diagnostics.
When a call goes wrong¶
Each client keeps two failures apart, because the service does: a call that was refused (no such symbol, a capability missing, a service that would not start) and a call that succeeded with an answer reporting a model failure (an expression that will not evaluate). A false verdict from a constraint is neither — it is an answer about the model.
*StatusError for the first (errors.Is(err, opensysml.CodeNotFound)), *FailureError for the
second (errors.Is(err, opensysml.ErrFailure)).
ServiceError for the first, with a subclass per status a caller acts on
(ModelNotFoundError, ServiceTimeoutError, …); ExecutionError, SymbolNotFoundError and
ModelError for the second. Everything descends from OpenSysMLError, and each also inherits
the built-in it stands in for, so except KeyError catches a missing symbol.
ServiceError for the first, carrying the Connect code; EvaluationError and
SymbolNotFoundError for the second. Both are instanceof OpenSysMLError.
ServiceException for the first, ModelException for the second, both unchecked and both
OpenSysMLException. TransportException, CapabilityException and ServiceStartException
name the specific refusals.
One Error enum: Error::Service for the first, Error::Model for the second.
From Go¶
A Go program that imports OpenSysML links the parser, the semantic engine and the runtime, so
opensysml.New() calls them directly — no port, no child process, no serialization:
client, err := opensysml.New()
if err != nil { ... }
defer client.Close()
model, err := client.ParseFile(ctx, "vehicle.sysml")
mass, err := client.Evaluate(ctx, model, "mass", opensysml.WithSubject("Demo::sedan"))
inst, err := client.Instantiate(ctx, model, "Demo::Vehicle")
Nothing else needs installing: the SysML standard library is embedded in the module and no
operation shells out. Every RPC the service offers is a method (ParseFiles for a model made of
several documents, ExecuteAction and ExecuteState, VerifyConstraint, VerifyRequirement,
VerifySatisfaction, EvaluateCalc, RunAnalysis, ListEngines, Query, RunDocumentQuery,
RenderDocument, Convert and ApplyEdits), and queries and edits are built from typed values rather than a string dialect, so
an unsupported operator is a compile error rather than a refused call.
A run with more than one valid order
is scheduled by the same spellings sysml -schedule takes: ExecuteAction and ExecuteState take
opensysml.WithSchedule("declared") ("reverse", the default, or "seed:<n>"; "replay:<file>" is
refused with INVALID_ARGUMENT, a request carrying no file of the caller's), RunAnalysis
takes opensysml.Schedule(...), and ExploreAction, ExploreState and ExploreAnalysis run
every linearization under "explore" — the default when no policy is given — or
"explore:runs=N,depth=D", answering an *Exploration (Outcomes, each with Linearizations,
Witness and Failed(); Complete, Runs, BudgetsHit, Status()). Handing explore to
ExecuteAction, or a one-run policy to ExploreAction, is refused with CodeInvalidArgument
before anything is sent; a service that does not advertise schedule or schedule_explore
refuses with CodeUnimplemented.
Which analysis engine answers is chosen the same way
sysml -engine chooses it: VerifyConstraint, VerifyRequirement and VerifySatisfaction take
opensysml.WithEngine("run"), RunAnalysis takes opensysml.Engine(...), Calculate — EvaluateCalc
with options — takes opensysml.CalcEngine(...) beside opensysml.CalcArguments(...), opensysml.EngineAll
asks every engine that covers the question and opensysml.EngineAuto — the default — leaves the
choice to the service. ListEngines names the engines the service registers — the check and
smt model checkers among them, though no request yet asks the holds question they answer, so
naming one is its typed refusal until the wire gains that request. Every Verdict,
Calculation and Analysis carries a Standing — the Engine that answered, the Strength of
its evidence and the Bounds it ran under, each Reached or not — which is what the standing:
line under a REPL verdict prints. A service that does not advertise engines refuses a named
engine with CodeUnimplemented before anything is sent. The
external engines of the service's OPENSYSML_ENGINES are
among those ListEngines names, but naming one is refused with CodeFailedPrecondition
(engine 'spin-bridge' is not served by this service) and EngineAuto and EngineAll pass over
it until the service is started with -serve-external-engines <name>,… or
-serve-external-engines all — a grant of command execution to every client, which is why it is
the operator's to give and not the client's to ask for.
Dial("host:50051") is the other constructor, for a shared sysml-grpc that someone else runs.
This package never spawns a service of its own, because a private child would only be serving the
same code New already calls directly.
A call fails in exactly one of two ways, and the distinction comes from the wire contract: a
refused call is a *StatusError (errors.Is(err, opensysml.CodeNotFound)), while an answer that
reports a failure is a *FailureError (errors.Is(err, opensysml.ErrFailure)). Syntax errors are
neither: parsing broken source succeeds, and the errors show up in Model.Diagnostics. Likewise, a
false verdict is an answer about the model, not an error. A trade study's RunAnalysis reports
each alternative's evaluation in Analysis.Evaluations (Selected() for the one picked, resolved
to its object by Analysis.Instance), and a run that fails part way is an *AnalysisError whose
Partial keeps what it computed.
The Go package reference documents the surface type by type, and client/opensysml/README.md states its concurrency, ownership and stability promises.
From Python¶
opensysml is the Python client. Every call goes through the sysml-grpc service, which the
client starts and stops automatically, so a script can parse, inspect, execute and convert a model
without running sysml as a subprocess. The complete API, the generated typed classes and
the measured latency are documented in reference/python-api.md.
Installation¶
pip install opensysml # from PyPI
pip install -e clients/python/ # or from a checkout, at the repository root
The dependencies (grpcio, protobuf>=7.35.1, filelock, psutil) are installed with it.
Those projects publish wheels for CPython 3.10 and later only, which is the range
requires-python declares.
Getting the service binary¶
Every call goes through sysml-grpc. The client starts an instance of the service, looking for
the binary in this order: $OPENSYSML_BINARY, then
~/.opensysml/bin/sysml-grpc (.exe on Windows), then a release download into that cache if
one was requested, then sysml-grpc on $PATH. There are five ways to provide it:
# 1. Download the release build (verified against the digest pinned in opensysml)
python -c "from opensysml.binary import download_binary; download_binary('latest')"
# 2. Have opensysml.connect() download it on first use
export OPENSYSML_GRPC_VERSION=latest # or a tag like v0.0.5
# 3. Build from source
make build-grpc && mkdir -p ~/.opensysml/bin && cp bin/sysml-grpc ~/.opensysml/bin/
# 4. Name a build to start, wherever it is
export OPENSYSML_BINARY=$PWD/bin/sysml-grpc
# 5. Install one on $PATH, with a package manager or `go install`
If none of these is in place, connect() raises ConnectionError listing everywhere it looked,
rather than downloading anything you did not ask for. A binary from $OPENSYSML_BINARY or $PATH
belongs to no release, so it is started as found: not copied into the cache and not verified
against the pinned digests described below. If $OPENSYSML_BINARY names something that is not
executable, that is an error, not a reason to look elsewhere. OPENSYSML_GITHUB_REPO overrides
the repository releases are fetched from (default Open-MBEE/OpenSysML).
A download records its release tag, repository and digest beside the binary
(~/.opensysml/bin/sysml-grpc.json). That way a cache left by an earlier release, or by another
repository publishing the same tag, is replaced rather than handed to a client asking for a newer
one. Without this check an older build would answer and the call would fail with a
MissingCapabilityError naming a capability the requested release does provide. The check only
runs when a release is requested, so when OPENSYSML_GRPC_VERSION is unset a manually installed
binary (option 3) is left alone. If the requested release cannot be downloaded, because there is no
asset for the platform or no network, the cached binary keeps serving and a warning says so rather
than the connection failing.
A download is verified against the SHA-256 digest that opensysml pins for that release, not
against the .sha256 file served beside the binary. That sidecar comes from whoever served
the binary, so it catches corruption but not a republished release. A release this
opensysml pins no digest for is refused, with the version named, and a working cached binary is
kept rather than trusting the served checksum. Setting
OPENSYSML_ALLOW_UNPINNED_DOWNLOAD=<owner/repo> (or =1 for any repository; a fork's releases
do not need this) explicitly accepts same-origin trust for the named repository, with a warning.
The binary is cached in ~/.opensysml/bin. A service started by the client is a private child of
the interpreter and keeps no state on disk, so OPENSYSML_STATE_DIR, which used to relocate the
service records, no longer has any effect.
Releases up to v0.0.4 contain only the sysml and sysml-lsp archives. sysml-grpc binaries are
published from the next release onward; until then, build the service from source
(option 3).
A first script¶
import opensysml
model = opensysml.load("model.sysml")
for d in model.diagnostics:
print(d)
print(model.eval("1 + 2 * 3"))
Evaluation is a method on the model, like every other operation, so a script never has to carry
the model hash back to the connection. model.eval(expr, context_symbol_id=…) resolves the
expression's names in that element's scope.
It evaluates at model level, over what the declarations state. An expression whose answer
the model leaves open — it reads an attribute with no value, or the count of a [1..*] or
[0..2] feature — is not an error but an opensysml.Undetermined, which prints as
<undetermined>, carries the reason and the count_lower/count_upper bounds of what
it stands for, and refuses bool() with a TypeError so a script cannot mistake it for
False. What the model does fix still answers: (u > 3) and false is False. It is neither
opensysml.UNSET (a feature an object holds nothing for) nor None (the model's null):
u = model.eval("T::u") # attribute u; with no value
isinstance(u, opensysml.Undetermined) # True
str(u), u.reason # ('<undetermined>', 'T::u has no value in the model')
model.eval("(T::u > 3) and false") # False
model.eval("SequenceFunctions::size(T::rack.gear)") # Undetermined, gear is [1..*]
Requiring a usable model¶
A model with syntax errors still parses to a Model, because the service reports what it
could read along with the diagnostics. A script that does not look at those diagnostics is
therefore querying a model with declarations missing. To require a valid model instead:
model = opensysml.load("model.sysml")
model.ok # False when any diagnostic is an error
model.errors # only the error-severity diagnostics
model.raise_for_errors() # raises ModelError, or returns the model
model = opensysml.load("model.sysml", strict=True) # raises instead of returning
A Diagnostic carries severity, message, code and its location. Branch on code, not on
the message text: "syntax" for a syntax error, a validation code such as "unresolved" or
"feature-value-overriding" for a finding, "choice-point" and "guard-unevaluable" for a
run's notes, and "" when the service assigned none — every code from a service that does not
advertise diagnostic_codes:
strict=True is available on opensysml.load, Connection.load and
Connection.load_from_content. The ModelError it raises carries the errors as
.diagnostics and the model itself as .model, so a caller that wants to report
them does not have to load twice:
try:
model = opensysml.load("model.sysml", strict=True)
except opensysml.ModelError as exc:
for d in exc.diagnostics:
print(d)
partial = exc.model # what the service did parse
strict_conformance=True, available on the same three calls, answers a different question:
whether the source is conforming SysML v2. OpenSysML's own notation, such as defer and the
pseudostates, is then reported as an error rather than a warning
(3. Strict conformance). The two settings are
independent: strict decides whether errors raise, and strict_conformance decides what
counts as an error.
model = opensysml.load("model.sysml", strict_conformance=True)
model.ok # False if the model uses OpenSysML notation
A service that predates the field raises MissingCapabilityError rather than silently answering
the default question. Support is advertised as strict_conformance in
Connection.server_info().capabilities.
Inspecting symbols¶
Model.find takes a short name and searches the symbol tree, returning None when there is
no such symbol. model["Vehicle"] is the raising counterpart: it names the missing symbol, whereas
chaining off find's None would fail one call later with AttributeError: 'NoneType' object
has no attribute 'attributes'.
vehicle = model["Vehicle"] # SymbolNotFoundError if absent; also a KeyError
vehicle.attributes() # [Symbol(id='Demo::Vehicle::mass', kind='attributeUsage')]
vehicle.parts() # [Symbol(id='Demo::Vehicle::engine', kind='partUsage')]
vehicle.get_attr("mass") # Symbol, or None if there is no such attribute
model.find("Nope") # None — for asking whether a symbol exists
"Vehicle" in model # True
model["Vehcile"] # SymbolNotFoundError: ... did you mean 'Vehicle'?
The subscript accepts a short name or a fully-qualified name. model.get("Demo::Vehicle") looks a
symbol up by fully-qualified name only and returns None if it finds nothing.
All four lookups ask the service's symbol index rather than walking the tree: a qualified name is
one GetSymbol, a short name one query and one fetch, so neither grows with the model. The index
covers the standard library too, so model.get("ScalarValues::Real") or
model.get("ISQBase::mass") answers a Symbol even though the library is not part of the
tree, while a short name (model.find("Real")) is still looked for in the model's own
declarations only. Where a model declares a package spelled like a library one, the model's
wins.
A symbol also carries its static type facts, as the service resolved them:
engine = model.get("Demo::Vehicle::engine")
engine.type_facts # TypeFacts(declared='Engine', resolved_id='Demo::Engine', ...)
engine.multiplicity # Multiplicity(lower='0', upper='1'), or None if undeclared
engine.specializations # [Specialization(kind='typing', target_id='Demo::Engine', ...)]
Instances¶
Feature values come back as Python values, and a feature value that holds an object comes back as
a nested Instance:
inst = model.instantiate("Demo::Vehicle")
inst.mass # 1500.0
inst["mass"] # 1500.0
inst.engine # Instance(id=2, type='Demo::Engine', features=1)
inst.engine.power # 300.0
inst.features # {'mass': 1500.0, 'engine': Instance(...)}
inst.get("missing", 0) # 0
Integers, reals, booleans, strings and sequences map to int, float, bool, str and list.
Unknown names raise AttributeError for attribute access or KeyError for item access, so
hasattr, copy and pickle behave as expected.
A feature value that holds nothing, such as a feature declared attribute d : Real; with no
value, reads as opensysml.UNSET, which %features and -instantiate show as
<unset>. It is falsy and is not None, which still stands for the model's null:
The service expands the object graph to depth 8 and stops at a type already present on the path,
so a part that contains its own kind terminates. A child that was not expanded comes back as its
bare integer id rather than an Instance.
The raw protobuf is still reachable: get_feature(name) returns the FeatureValue message, and
raw_features is the complete map.
A feature value the service could not evaluate, such as a cyclic derived attribute, is never
reported as None. Attribute and item access raise FeatureValueError, while features carries
the FeatureValueError as that entry's value so the rest of the instance stays inspectable.
cyclic.a # raises FeatureValueError: feature value 'a': ... cyclic feature value dependency
cyclic.features["a"] # FeatureValueError(...)
FeatureValueError is not an AttributeError, so hasattr on such a feature propagates it
rather than returning False. Use features to inspect an instance whose feature values may have
failed to evaluate.
eval returns a single value, so a result the wire format cannot represent raises
UnsupportedValueError rather than being reported per entry.
Every value the wire carries¶
A feature value, an evaluation, an argument and an output travel as one of the value kinds the
wire contract fixes, and each arrives as a Python value of its
own, never as a string to parse or a dict to unpick. A quantity (3 [SI::kg]) is an
opensysml.Quantity with its Unit, and the others each have a class in opensysml:
model.eval("3.0 + 4.0 * ComplexFunctions::i") # (3+4j), a complex
model.eval("Demo::grid") # Array(dimensions=(2, 3), elements=(1, 2, 3, 4, 5, 6))
model.eval("Demo::grid").nested() # [[1, 2, 3], [4, 5, 6]]
model.eval("Demo::vec") # Vector(components=(3.0, 4.0))
model.eval("Demo::vq") # VectorQuantity(components=(Quantity(3.0, m), Quantity(4.0, m)))
model.eval("Demo::plane") # TensorQuantity(dimensions=(2, 2), components=(...)), plane[1, 1]
model.eval("Demo::s.elements") # SetValue(elements=(1, 2, 3)), == SetValue((3, 2, 1))
model.eval("SI::m") # MeasurementRef(unit=Unit(text='m', ...), unit_id='SI::metre')
model.eval("Demo::Sq") # Function(calc_id='Demo::Sq', self_id=0), a calc as a value
model.eval("Demo::seatBelt meta KerML::Feature") # [Metaobject(element_id='Demo::seatBelt', metaclass_id='SysML::Systems::PartUsage')]
model.eval("Demo::lvl") # EnumLiteral(literal_id='Demo::Level::high', ..., value=3)
model.eval("Demo::inf") is opensysml.INFINITY # True, for an unbounded `*`
An enumeration literal is always an EnumLiteral, equal to another by the literal's identity
(literal_id); a literal of an enum def that specializes a scalar (enum def Level :>
Integer { high = 3; }) also carries that scalar as value, and 3 as Demo::Level answers the
same literal rather than the integer 3. A SetValue is unordered and holds each member once;
a TensorQuantity is indexed by one integer per dimension. A Function is passed back as an
argument where a calc takes a calc (model.calc("Demo::apply", arguments=[opensysml.Function("Demo::Sq"), 3.0])).
A Metaobject is what x meta T and the last element of x.metadata evaluate to: the element's
identity and its reflective metaclass, whose features (.name, .qualifiedName) evaluate
through it. Each kind is capability-negotiated (complex_values, structured_values,
measurement_refs, function_values, set_values, tensor_values, metaobject_values,
infinity_value, undetermined_value, enum_values): a value a service predating one
cannot send arrives as UnsupportedValueError (in place, for a feature value or an output;
raised, from eval), and sending such a value as an argument to that service is refused
before anything goes over the wire, so a script can check
Connection.server_info().capabilities before relying on one.
Instances are capability-negotiated the same way as conversion: a service that does not report
the feature_values capability (every release before 0.1.0) raises MissingCapabilityError
naming the required upgrade, rather than returning an object whose values all appear to be
missing.
Actions and state machines run on the model too:
model.execute_action("Demo::addFive", inputs={"result": 10}) # {'result': 15}
model.execute_state("Demo::Machine", events=["go"]) # {'states_visited': [...], ...}
execute_action and execute_state treat their result maps the same way:
a value the wire format cannot represent is reported as an
UnsupportedValueError in that entry, leaving the other entries intact.
A run waits on time where its behavior does — an action's accept after 5 [SI::s], a
machine's time-triggered transition — on a simulation clock of its own that starts at 0, and
execute_state's 'final_time' is that clock, in seconds, when the run ended (0.0 from a
service that predates the final_time capability).
Both take a schedule= — "declared", "reverse" (the default) or "seed:<n>", the spellings
sysml -schedule takes, less "replay:<file>", which the service refuses — for a run with more than one valid
order; run_analysis takes the same.
The explore policy answers every outcome rather than one run's, so it has methods of its own:
exploration = model.explore_action("Demo::race") # schedule="explore" by default
for outcome in exploration: # canonical order, the same every time
print(outcome.outputs, outcome.linearizations, outcome.witness)
exploration.complete, exploration.runs, exploration.budgets_hit # (True, 6, [])
model.explore_action("Demo::race", schedule="explore:runs=2").budgets_hit # ['runs']
explore_state and explore_analysis are the state-machine and analysis-case forms; an outcome
of a machine carries final_state and states_visited, and a run that failed under some order is
an outcome with error set. Passing "explore" to execute_action (or a one-run policy to
explore_action) raises ValueError before anything is sent; a service predating the
schedule or schedule_explore capability raises MissingCapabilityError.
Every call about a loaded model is a Model method. The module-level
opensysml.instantiate, opensysml.evaluate and opensysml.convert are still available for
working directly from a file (opensysml.instantiate("Demo::Vehicle",
file_path="model.sysml")) or from a hash obtained elsewhere (model_hash=…).
Verifying constraints, requirements, satisfaction, calculations and analyses¶
The checks the REPL performs with %constraint, %requirement, %satisfy, %calc and %analysis are also
available as RPCs, so a script can find out whether a model satisfies its requirements.
These calls use the same runtime evaluation the REPL does, not a second implementation.
model = opensysml.load("lander.sysml", strict=True)
for verdict in model.verify_satisfaction(): # every assert satisfy … by …
print(verdict)
# ✓ satisfy touchdown by slowLander holds (on Landing::slowLander ID: 1) — observed by run
# ✗ satisfy touchdown by fastLander fails (on Landing::fastLander ID: 2): condition
# evaluated to false: lander.verticalSpeed <= maxVerticalSpeed — witnessed by run
model.satisfied() # False — one assertion fails
model.verify_satisfaction("Landing::analysisContext") # only what that element asserts
Constraints and requirements are named directly, optionally with a subject to instantiate, so that the verdict is about that object's values rather than the declared defaults:
model.verify_constraint("Demo::Vehicle::massOK", subject="Demo::sedan")
model.verify_requirement("Demo::Vehicle::lightEnough", subject="Demo::sedan")
A Verdict is truthy when the condition holds, and reports the reason when it does not:
verdict = model.verify_requirement("Demo::Vehicle::lightEnough", subject="Demo::truck")
bool(verdict) # False
verdict.holds # False
verdict.condition # 'mass < 2000.0' — the condition that evaluated to false
verdict.element # 'Demo::Vehicle::lightEnough', or the assertion as written
verdict.kind # 'constraint', 'requirement' or 'satisfy'
verdict.instance_id # the object the verdict is about, 0 for declared defaults
verdict.instances # the objects the call built, as Instances
verdict.diagnostics # diagnostics the service reported for the run
print(verdict.explain())
A false verdict is a result, not an exception. A condition that evaluated to false is the
answer to the question you asked, so it is returned. Only a failure to evaluate at all, such as an
unbound feature or an exhausted step budget, is a malfunction, and such a failure is not
reported as a failing verdict. It appears as verdict.error with verdict.evaluated set to
False, and verdict.raise_for_error() turns it into an ExecutionError for scripts that must
not mistake it for a failed requirement:
for verdict in model.verify_satisfaction():
verdict.raise_for_error() # nothing raised for a verdict of false
if not verdict:
print(verdict.explain())
Where the model states verification cases for a requirement, the verdict their bodies produced is
reported beside the requirement's own — verdict.holds still says what the requirement engine
decided, and a failing verification body does not change it:
verdict = model.verify_requirement("Landing::touchdown")
verdict.holds # True — the requirement is satisfied
[(v.case_id, v.kind) for v in verdict.verifications]
# [('Landing::checkSlow', 'pass'), ('Landing::checkFast', 'fail')]
bool(verdict.verifications[0]) # True — a VerificationVerdict is truthy when it passed
print(verdict.verifications[1].explain())
A kind of "pass" or "fail" is what the library's own VerificationCases::PassIf calculation
computed over the run of the case body, "inconclusive" a body that produced no verdict value and
"error" one whose run could not be carried out, with detail carrying the reason. subcase is
true for a case another case performed as a step. run_analysis accepts a verification case and
reports the same list, and verify_satisfaction's verdicts carry it too. A service that does not
advertise verification_verdicts (opensysml.capabilities.CAPABILITY_VERIFICATION_VERDICTS)
reports none.
A request that cannot be answered at all, such as one naming an unknown symbol or a subject that
cannot be instantiated, raises ExecutionError from the call itself rather than returning a
verdict. Narrowing to an element that makes no satisfaction assertion is not such a request: it
returns no verdicts, and satisfied() is then vacuously True.
Naming an element of the wrong kind is an invalid request, not a verdict. Asking whether a
part def holds as a constraint raises WrongKindError, a subclass of ExecutionError, from
verify_constraint, verify_requirement, verify_satisfaction and calc, exactly as naming a
nonexistent element does. A caller reading .holds is therefore never told the model does
not hold when the real problem is that a part def was named:
model.verify_constraint("Demo::Wheel")
# opensysml.errors.WrongKindError: not a constraint: Demo::Wheel is a part def,
# not a constraint definition or usage
The kind is read from a typed failure_reason reported by the service, never from the message
text.
verify_satisfaction evaluates many assertions in one call and reports a single object graph for
all of them, so verdict.instances holds every object the call built. Pick out the object a
verdict is about using its instance_id:
Calculations are invoked with positional arguments. A calc usage named without arguments is evaluated from its own members and reports every output feature it computes (SysML 7.17):
model.calc("Demo::add", arguments=[2.5, 4.0]).value # 6.5
model.calc("Demo::c").outputs # {'a': 6, 'b': 10}
An analysis case is run the way %analysis runs one: a usage that binds its subject needs
nothing more, a definition (or a usage binding none) takes the subject to instantiate, and
arguments and named_arguments bind its other in parameters. The result carries the case's
out and return values, one verdict per objective and per assert constraint in the body,
and the subject's objects; it is truthy when every verdict was decided and holds:
run = model.run_analysis("An::shipCost")
run.outputs # {'total': 12.0}
run.verdicts[0].holds # True — objective affordable
run = model.run_analysis("An::CostAnalysis", subject="An::barge",
named_arguments={"limit": 50.0})
run.outputs["total"], bool(run) # (37.0, True)
run = model.run_analysis("An::CostAnalysis", subject="An::barge")
bool(run), run.verdicts[0].condition # (False, 'total <= limit')
An objective that could not be decided — a feature it reads has no value — is a verdict with
error set and evaluated false, as a verification's is, and raise_for_error() turns it into
an ExecutionError. A definition run with no subject, an in parameter left without a value, a
step that fails and a case that runs itself raise ExecutionError from the call; naming a symbol
that is not an analysis raises WrongKindError, and so does asking calc to run an analysis.
A trade study (TradeStudies::TradeStudy) runs the same way and reports in addition each
evaluation the run made of its evaluationFunction — one per alternative the subject lists, in
subject order, as a CaseEvaluation carrying the alternative it scored (an Instance), the
result or the error, and whether it was the one selected (selected) or scored the same without
being (tied); run.selected is the selected ones. A service that does not advertise
case_evaluations (opensysml.capabilities.CAPABILITY_CASE_EVALUATIONS) reports none:
run = model.run_analysis("Trade::lightest")
run.outputs["selectedAlternative"].type_symbol_id # 'Trade::b'
[(e.arguments[0].type_symbol_id, e.result, e.selected, e.tied) for e in run.evaluations]
# [('Trade::a', 30.0, False, False), ('Trade::b', 10.0, True, False), ('Trade::c', 10.0, False, True)]
run.selected[0].arguments[0].id == run.outputs["selectedAlternative"].id # True
An evaluationFunction that fails for one alternative fails the run as any failing step does,
with AnalysisRunError — an ExecutionError whose result keeps what the run computed before
it failed: the evaluations made, the earlier ones with their results and the failing one with its
error, and the objective undecided. Swept, that run is a row keeping the same beside its own
error.
A parameter can be swept rather than fixed: run_sweep runs the analysis case or calc once per
value of each range and returns a SweepTable, whose rows carry the inputs bound for that run,
its outputs and verdicts, and the seconds it took. Ranges are {parameter: (from, to)} or
{parameter: (from, to, step)}; several of them run their cartesian product, the first varying
slowest, and samples with seed draws that many values per range instead:
table = model.run_sweep("An::CostAnalysis", {"limit": (10.0, 50.0, 20.0)}, subject="An::barge")
[row.inputs["limit"] for row in table] # [10.0, 30.0, 50.0]
table[0].outputs["total"], table[0].verdicts["affordable"]
drawn = model.run_sweep("An::CostAnalysis", {"limit": (10.0, 50.0)}, subject="An::barge",
samples=8, seed=42)
drawn.sampled, drawn.seed, len(drawn) # (True, 42, 8)
A run that fails is a row of its own — falsy, with error set — rather than an exception, and
the runs after it are still made, so table.failures collects them; raise_for_error() on a row
turns one into an ExecutionError. A plan the service refuses raises ExecutionError instead,
naming what is wrong with it: a step of zero, a step whose sign never reaches its end, a Real
range with no step, incompatible units, a parameter the target declares none of, a case's
subject, one the arguments already bind, a distribution asked for by name, and a plan asking for
more runs than the service's budget allows. A row's verdicts resolve against table.instances,
which carries the objects the runs were about, so a failing row's subject can be read. A row of
a trade study carries that run's evaluations and selected as run_analysis's result does,
so a table shows where the pick changes as the parameter moves.
Verification is capability-negotiated the same way as conversion: against a service that does
not report the verification capability, these calls raise MissingCapabilityError naming the
required upgrade rather than failing on an unimplemented method.
Choosing the engine, and reading the standing of an answer¶
Which analysis engine answers is chosen as sysml -engine
chooses it: verify_constraint, verify_requirement, verify_satisfaction, calc,
run_analysis and run_sweep take engine= — "auto" (the default, the service picks),
"all" (every engine that covers the question, composed) or one by name; explore_analysis
takes a schedule= instead and leaves the engine to the service. A
name the service does not register raises InvalidRequestError listing the ones it does, and
an engine that does not cover the question answers a verdict with error set (check does not
answer evaluate questions) rather than a false one. Connection.list_engines() reports the
engines, as sysml -engines does:
for engine in model.connection.list_engines():
print(engine)
# check: bounded, answers outcomes, holds; ready
# explore: proved, answers outcomes; ready
# run: observed, answers evaluate; ready
# solve: proved, answers satisfiable; ready
# sweep: observed, answers sweep; ready
An EngineInfo carries the name that engine= selects, the authority it may claim, the
question kinds it answers, the bounds it runs under, the external process it needs (the
solve engine's SMT solver) and whether it is ready here, with unavailable saying why not.
Every Verdict, CalcResult and AnalysisResult carries a Standing — the engine that
answered, the strength of its evidence (observed, witnessed, bounded, proved) and the
bounds it ran under, each marked reached when the engine stopped at it — which is what the
standing: line under a REPL verdict prints. explain() ends with it:
verdict = model.verify_constraint("Demo::Vehicle::massOK", subject="Demo::sedan")
verdict.standing # Standing(engine='run', strength='observed', bounds=(...))
verdict.engine, verdict.strength, verdict.bounds
str(verdict.standing) # 'observed by run'
verdict.explain() # '✓ constraint Demo::Vehicle::massOK holds (on Demo::sedan ID: 1) — observed by run'
A service that does not advertise engines raises MissingCapabilityError for a named engine
and for list_engines(), and reports no standing (standing.reported is False).
Errors¶
Every failure a caller can act on is an OpenSysMLError. The service's gRPC status codes are
translated at the client boundary, so a script never needs to import grpc and switch on status
codes. The original grpc.RpcError is still available as __cause__ for its debug string.
OpenSysMLError
├── ConnectionError service unreachable or would not start (UNAVAILABLE)
│ ├── StaleServiceError another release is already listening on that address
│ └── ChecksumMismatchError a download contradicts the digest pinned for it
│ └── UnpinnedReleaseError this opensysml pins no digest for that release
├── ServiceError any other status the service failed a call with
│ ├── ModelNotFoundError the model hash is no longer in the service cache
│ ├── ModelFileNotFoundError the service could not read the path (also FileNotFoundError)
│ ├── InvalidRequestError request rejected as malformed (also ValueError)
│ ├── ServiceTimeoutError deadline exceeded or cancelled (also TimeoutError)
│ └── UnsupportedOperationError the service does not implement the call
├── ExecutionError eval/instantiate/execute/verify failed (also RuntimeError)
│ ├── WrongKindError the call named an element of another kind than it asks about
│ └── AnalysisRunError an analysis failed part way; `result` keeps what it computed
├── ModelError strict load of a model with error diagnostics
├── SymbolNotFoundError model["Nope"] (also KeyError)
├── FeatureValueError a feature value could not be evaluated
├── ConversionError the model could not be written in that format
├── EditError an edit was refused; see "Editing a model" for the subclasses
├── QueryError a standard Query payload the standard does not describe
├── DocumentQueryError a document-query binding of a type the wire cannot carry
├── UnsupportedValueError a value the wire format cannot represent
├── TypeMismatchError a feature value contradicts its generated view
├── InstanceTypeError a typed view was given an instance of another type
└── MissingCapabilityError the connected service does not report the capability
ServiceError.code is the underlying grpc.StatusCode. A status this client does not recognize
is still delivered as a ServiceError, so nothing escapes the hierarchy.
The two most common failures are both NOT_FOUND on the wire but need different fixes, so the
client tells them apart from what the service reports:
opensysml.load("/tmp/nope.sysml") # ModelFileNotFoundError: file not found: …
model.to_turtle() # ModelNotFoundError if the model was evicted
ExecutionError inherits from the built-in RuntimeError, so except RuntimeError: catches it.
A traceback reading opensysml.errors.RuntimeError used to suggest that but did not deliver it.
The old name is kept as a deprecated alias of ExecutionError (it is the same class, so
existing except opensysml.errors.RuntimeError clauses still work) and emits a
DeprecationWarning when accessed. Inheriting from the built-in, rather than only renaming, also
fixes existing code that never caught the old class, and the alias is left out of __all__ so a
star-import no longer shadows the built-in.
Names that no longer shadow a built-in¶
Both names the package used to bind over a Python built-in were renamed before 0.2.0 was published, so no deprecation cycle is needed:
| Old name | Use instead |
|---|---|
opensysml.eval |
opensysml.evaluate |
opensysml.RuntimeError, opensysml.errors.RuntimeError |
opensysml.ExecutionError |
Each old name still resolves to the same object (opensysml.eval is opensysml.evaluate
and opensysml.RuntimeError is ExecutionError, so existing snippets and except clauses
still work) and emits a DeprecationWarning when accessed. Neither appears in __all__, so
from opensysml import * no longer binds over eval or RuntimeError. The Model.eval and
Connection.eval methods keep their name, because an attribute of an object shadows nothing.
Writing a model back out¶
A loaded model can be written back to SysML notation or RDF Turtle. The service does the
conversion with the same code as sysml -convert, so the client adds no second
implementation of the mapping.
The Turtle direction is experimental: it
carries model structure and the behavior written in model bodies, refuses any construct it cannot
write back, and its vocabulary may change without a compatibility path. Any conversion that uses it
issues an ExperimentalFeatureWarning and sets Conversion.experimental. The notation direction
is stable and issues no warning.
model = opensysml.load("model.sysml")
model.to_sysml() # Conversion: SysML notation
model.to_turtle() # Conversion: RDF Turtle
model.save("model.ttl") # writes Turtle; format taken from the extension
model.save("out.sysml")
opensysml.convert("ttl", file_path="model.sysml") # without loading first
opensysml.convert("sysml", content=turtle, from_format="ttl") # Turtle back to notation
A Conversion holds the output text, the formats converted between, and whether the mapping
used is experimental, with experimental_notice explaining why. str() and len() return the
text, and write(path) saves it. Formats are named sysml, kerml, text, ttl, turtle or
rdf. A file path's format is inferred from its extension; inline content has no extension, so
it needs from_format.
A Model writes out the source the service parsed, identified by model.hash, so editing the file
between load and save does not change what is written: the model saved is the model you
inspected. convert(file_path=…) is the alternative, and reads the file as it is now. The
parsed source lives in the service's bounded cache, so a model evicted since it was loaded
raises ModelNotFoundError rather than writing different content; load it again.
What each direction preserves:
- Notation → notation re-emits the model from its source, so comments and layout are preserved. It preserves source rather than printing an AST in general, so a model the client built element by element cannot be printed this way.
- Notation → Turtle → notation gives back an equivalent model rather than identical bytes. Comments do not survive the graph, because RDF has no place to record them. See the RDF mapping for what a graph must carry to support the return trip.
- Syntax errors normally fail the conversion and come back as a
ConversionErrorcarryingdiagnostics.tolerate_syntax_errors=Truewrites notation anyway and reports the errors asConversion.diagnostics. It applies to the notation → notation direction only, because every other direction builds a graph in which an unparsed declaration would be silently dropped.
Conversion is capability-negotiated: against a service that does not report the convert
capability, these calls raise MissingCapabilityError naming the required upgrade rather than
failing on an unimplemented method. For a service that does not report the RDF mapping's status,
the status is worked out from the formats it reports, so an RDF conversion warns either way.
Suppress the warning with warnings.simplefilter("ignore",
opensysml.ExperimentalFeatureWarning); no stable feature uses that warning class.
Changing a model and writing it back¶
A loaded model can be edited in place (setting the value of a feature, renaming a declaration) and written back with its comments, blank lines and indentation intact. You describe the edit rather than typing it out: the client sends operations naming elements by the same ids a read reports, and the service applies them to the source it parsed.
model = opensysml.load("spacecraft.sysml")
edit = model.edit()
edit.set_value("Demo::sc::unitMass", "1050.0[SI::kg]")
edit.rename("Demo::sc::margin", "massMargin")
result = edit.apply() # re-parsed and validated service-side
result.save("spacecraft.sysml") # every byte outside an edited span unchanged
model.edit() returns an Editor. set_value(target, value) replaces an existing = <expr> on
a feature, or adds one before the closing ; when the feature has none; value is SysML
notation for a single expression, such as "1050.0[SI::kg]", '"flight-2"', "true" or
"unitMass * count". rename(target, new_name) rewrites a declaration's name token and the
references to it. A target is a symbol id (its fully-qualified name, as reported by Symbol.id)
or a Symbol itself, so an element you found with a read can be edited by passing it back. Both
calls return the editor, so operations can be chained, and len(edit) counts them.
The same editor can add declarations:
model = opensysml.loads("package Demo {}", strict=True)
result = model.edit().add_part_def("", "Vehicle").apply()
add_member(owner, kind, name, type=None, multiplicity=None, value=None, specializes=None)
accepts notation strings for the declaration. Typed add_* helpers cover the common SysML and
KerML kinds, delete(target, cascade=False) removes declarations transactionally, and
move(target, owner) carries a declaration, body and comments included, into another namespace
of the same document ("" is the document itself), respelling the references the move
would otherwise break.
apply() sends the operations in a single call and returns an EditResult, which is a
Conversion: str(result) is the edited notation, and result.save(path) and
result.write(path) write it. result.applied lists the changes as
AppliedEdit(operation_index, target, offset, length, old_text, new_text) in source order, where
length == 0 marks a value added to a feature that had none before.
How editing works:
- Spans, not text search. The value expression and the name token are located through the parsed model's own spans, so a comment or string that happens to contain the same text is never touched.
- Bytes are spliced and nothing is reformatted. Edits are applied right-to-left by offset in a single pass, and every byte outside an edited span is identical to the source the service parsed. Nothing is re-indented or re-wrapped.
- The result is read back before it is returned. The edited source is re-lexed, re-parsed and re-analysed. An edit that would introduce a syntax or name-resolution error is refused, with the diagnostics explaining why, and nothing is returned, so the service never produces a file its parser cannot read. Errors the model already had are not blamed on the edit; only errors the edit introduces cause a refusal.
Every refusal is a typed error, never a silent no-op:
| Situation | Error |
|---|---|
| No operation was added to the editor | NoEditsError |
| No such element, an ambiguous name, or an element that cannot carry a value or a name | EditTargetError |
| A new value that does not parse as one expression, or a new name that is not an identifier or already means something where the element is declared | InvalidEditError |
| A rename that would capture or shadow another name, at the declaration or at any reference | InvalidEditError |
| Two operations that would edit overlapping bytes | OverlappingEditsError |
| The edited model does not read back cleanly — a value naming something that does not resolve, say | EditResultError |
All of these are subclasses of EditError, which carries failure (the kind of refusal),
diagnostics, and referring_elements for a refused rename or a refused non-cascade delete. An
EditResultError's diagnostics have spans in the edited text. referring_elements names
each namespace a reference is made from, telling you where to look rather than which
expression is at fault.
try:
model.edit().rename("Demo::SC::margin", "label").apply()
except opensysml.InvalidEditError as refused:
print(refused.failure) # 'EDIT_FAILURE_INVALID_NAME'
A rename rewrites the declaration's name token and every reference to it in the model's source (the matching segment of a qualified name, an alias target, an import), so the model keeps its original meaning.
These limitations are intentional:
- A rename is refused where it would change what a name means. A new name that already means something where the element is declared (as a sibling, or reached through an enclosing namespace, an import or a supertype), or that already means something at one of the references being rewritten, is refused. Such a rename would either be ambiguous or shadow an existing declaration, and either way unrelated expressions would start resolving to the renamed element. References from another file are not rewritten, because an edit sees only the source of the model it was given.
- A model cannot be built from scratch in Python. Declarations are added to and deleted from a
model that is already loaded; there is no way to author one from nothing, and no object facade.
A declaration is described by the notation arguments of an
add_*call rather than by a mutable Python object. - An editor is applied once. It describes an edit of the model it was created from, so
applying it twice raises
RuntimeError. Load the saved file and edit that instead. - The parsed source lives in the service's bounded cache, so a model evicted since it was loaded
raises
ModelNotFoundErrorrather than editing different content; load it again.
Editing is capability-negotiated the same way as conversion: a service that does not report the
apply_edits capability raises MissingCapabilityError naming the required upgrade before any
call is made.
Querying a model using the standard query model¶
model.query(...) runs the query defined by the SysML v2 API & Services standard, using
scope, select and where, so a payload written for that API works verbatim. This is the form
the API Cookbook notebooks and MATLAB System Composer's executeQuery send:
model = opensysml.load("model.sysml")
model.query({"@type": "Query", "where": {
"@type": "PrimitiveConstraint",
"operator": "=", "property": "@type", "value": ["PartUsage"]}})
# [Demo::vehicle (PartUsage), Demo::vehicle::wheels (PartUsage)]
# The same query in keyword form, narrowed and projected
model.query(
scope=["Demo::vehicle"],
select=["name", "qualifiedName"],
where={"operator": "=", "property": "@type", "value": ["PartUsage"]},
)
Each result is a QueryElement with id (the element's qualified name), type (its
metamodel type, for example PartUsage) and properties, the selected properties the element
has. A property an element does not have is absent rather than empty. An unnamed element, such as
a doc note or an anonymous usage, is not returned at all, because it has no qualified name to
identify or scope it by. as_dict() returns the result using the standard's JSON
names. scope accepts qualified names or the standard's {"@id": …} references and covers
each named element together with everything nested inside it; an empty scope covers the whole
loaded model.
A payload the standard does not describe, such as one with an unknown operator or a constraint
without a property, raises QueryError before anything is sent. A property the service does not
provide raises InvalidRequestError naming the properties that do exist, rather than returning no
results, and an evicted model raises ModelNotFoundError. As with every other call, gRPC status
codes stop at the client boundary. The query is capability-negotiated the same way as
conversion: a service that does not report the query capability raises MissingCapabilityError.
The standard's query model has no graph traversal and no transitive closure: "everything
under this part" is expressed as a scope, while "everything specializing this definition" cannot
be expressed at all. It is an interoperability surface, not OpenSysML's expressive query
facility; the API reference states exactly what is supported.
Native document queries and rendered documents¶
model.run_document_query(...) runs a document query (a calc def specializing
DocumentQueries::Query) and model.render_document(...) renders a document (a part def
specializing DocumentQueries::Document), the way the REPL's %run-query and %render-document
do:
model = opensysml.load("report.sysml")
result = model.run_document_query("Observatory::SubsystemTable")
result.columns # ("name", "mass")
for row in result.rows:
row.element.id # "Observatory::telescope::optics"
row.element.type # "PartUsage"
row.cells # (("optics",), (8.5,))
# A parameterized query takes typed bindings; a list binds a multi-valued parameter
model.run_document_query("Observatory::HeavierThan", bindings={"threshold": 10.0})
markdown = model.render_document("Observatory::SubsystemReport")
A binding value is an element (opensysml.ElementRef("Demo::optics")), a str, an int, a
float, a bool, or a list of these; anything else raises DocumentQueryError before anything
is sent. Cell values come back with those Python types, an element as ElementRef and an
unbounded multiplicity as opensysml.INFINITY. render_document takes no bindings, because a
document binds its queries' parameters in the model; it returns the Markdown text, identical to
what sysml -render-document writes.
An unknown query or document raises SymbolNotFoundError, a bad binding raises
InvalidRequestError naming the parameter, and both calls are capability-negotiated
(document_query and render_document) the same way as everything above.
From Node or a browser¶
import { loads } from "@opensysml/client";
await using model = await loads(`package Demo {
part def Wheel { attribute radius : ScalarValues::Real = 0.3; }
part def Car { part wheels : Wheel[4]; attribute mass : ScalarValues::Real = 1500.0; }
}`);
const value = await model.eval("2 + 2");
const car = await model.symbol("Demo::Car");
const tree = await model.instantiate("Demo::Car");
tree.get("wheels");
@opensysml/client is not published yet, so build it from a checkout: npm install && npm run build
in clients/node. loads and load are the one-shot forms; connect() keeps a connection (and so
a service and its parse cache) open across several models. Both a connection and a model are
async-disposable, so await using closes them, and close() is the explicit form. Values arrive as
discriminated unions to switch on (value.kind === "quantity"), integers as bigint so an int64
is never rounded, unset (a feature an object holds nothing for) is distinct from absent
(a field the answer did not carry), and undetermined is a model-level answer the model
leaves open, with its reason and count bounds.
The same package runs in a browser, from a second entry point that spawns nothing:
import { connect } from "@opensysml/client/browser";
await using connection = await connect({ address: "https://sysml.example.com" });
That needs a service that allows the page's exact origin (sysml-grpc -cors-allowed-origins
https://app.example.com, never *), and TLS if an HTTPS page is to reach it at all.
The ergonomic layer covers GetServerInfo, ParseFile, GetSymbol, Evaluate and Instantiate;
connection.rpc is the generated Connect client and reaches everything else — the schedule
field of ExecuteActionRequest, ExecuteStateRequest and RunAnalysisRequest and the outcomes
and exploration an "explore" schedule answers with are read there, as the wire
contract spells them; the ergonomic layer has no method for them.
The Node API reference documents the exports, the errors and the binary
resolution.
From Java¶
try (Connection connection = Connection.open()) { // starts a private sysml-grpc
Model model = connection.load(Path.of("model.sysml"));
Value sum = model.eval("1 + 2 * 3"); // Value.IntegerValue[value=7]
Value mass = model.evalWithSubject("mass", "Demo::sedan");
Symbol vehicle = model.symbol("Demo::Vehicle"); // findSymbol returns Optional
Instantiation built = model.instantiate("Demo::Vehicle");
}
The client is meant to live inside a JVM host application it does not own (an Eclipse-based tool,
a Cameo plugin, a web service), so it is built for JDK 17 and its only compile-scope dependency is
protobuf-java. The transport is java.net.http.HttpClient speaking Connect, which keeps gRPC's
Netty out of a host that has its own. Nothing is published yet; make build followed by
mvn -f clients/java/pom.xml install puts it in your local repository.
Everything returned is immutable, and no protobuf message appears in the public API: Value is a
sealed interface over records, so its variants are closed and enumerable, and Symbol, Diagnostic,
Instance and Instantiation are records with copied collections. Everything thrown is unchecked
and descends from OpenSysMLException, with ServiceException (the call was refused) kept separate
from ModelException (the call succeeded and the answer reports a model failure).
One private service is started per classloader, so an Eclipse plugin and a web application in one JVM
each own one and share nothing, while every connection made through one copy of the client shares a
child and therefore its parse cache. Call Connection.stopSharedServices() from a plugin's stop()
or a ServletContextListener, since unloading a classloader does not by itself stop the child.
The typed surface stops short of execution (ExecuteAction, ExecuteState and RunAnalysis are
among what v1 does not do), so nothing here takes a schedule or reads outcomes;
Capabilities.SCHEDULE and Capabilities.SCHEDULE_EXPLORE only name the two capabilities a
service advertising scheduling policies reports.
The Java API reference documents the surface, the exceptions and the options.
From Rust¶
use opensysml::{loads, Value};
let model = loads("package Demo { part def Car { attribute mass : ScalarValues::Real = 1500.0; } }")?;
let value = model.eval("2 + 2")?; // Value::Integer(4)
let car = model.symbol("Demo::Car")?;
let built = model.instantiate("Demo::Car")?;
The crate is blocking and pulls in no async runtime: every one of the service's RPCs is unary and the
usual consumer talks to a local child that answers in milliseconds, so a private tokio::Runtime
inside a library would cost every consumer something for little gain. Calling it from inside a
runtime is fine, and a test pins that. It is not on crates.io yet, so take it from a path or from
git; the minimum supported Rust version is 1.83.
load/loads connect and parse in one call; Connection::private(), Connection::external(host,
port) and Connection::connect() (which honours $OPENSYSML_SERVICE) are the explicit forms.
Drop releases a private child deterministically, and the stdin pipe the client holds is what
makes cleanup survive a SIGKILL. Error is one enum over every failure, keeping Error::Service
(refused) apart from Error::Model (answered, and the answer reports a model failure), and every
domain type exposes wire() for any field the typed surface does not carry yet. Execution is not
covered by v1 at all, so there is no schedule to set: the opensysml::wire module carries the
generated ExecuteActionRequest with its schedule field and the Outcome and
ExplorationStatus messages, but no method sends them.
The Rust API reference documents the API, the error variants and the one gap in its release verification.
Next: 10. Troubleshooting.