"""Command-line interface: ``exlink <command>``.
Commands
--------
``analyse`` Print every objective and constraint of a design.
``animate`` Write a GIF or MP4 of the mechanism turning.
``plot`` Write the motion / cycle / torque / mechanism figures.
``optimize`` Maximise efficiency subject to all the constraints.
``refine`` Polish a design with the augmented Lagrangian.
``pareto`` Approximate the Pareto front of ``(-eta, H, B)``.
``cycle`` Score a design over a schedule of speeds, not one point.
``size`` Size the parts, solid or tubular (``--bore``).
A design is given either by name (``published``, ``refined``) or as a JSON file
written by ``--save``, so the commands chain::
exlink optimize --save best.json
exlink refine --design best.json --save final.json
exlink animate --design final.json -o final.gif
exlink animate --formulations -o formulations.gif
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from .constants import DEFAULT_SPEC
from .design import GLOBAL_BOUNDS, VARIABLE_DESCRIPTIONS, VARIABLE_NAMES, Bounds, Design
from .diagrams import RENDER_DPI
from .dynamics import DEFAULT_SPEED_RPM
from .model import analyse
from .reference import PUBLISHED_DESIGN
from .sections import DEFAULT_MIN_WALL
NAMED_DESIGNS = ("published", "refined", "coupled", "range", "reliable")
[docs]
def load_design(reference: str | None) -> Design:
"""Resolve a design from a name or a JSON file path.
Args:
reference: One of :data:`NAMED_DESIGNS`, a path to a JSON file, or
``None`` for :data:`~exlink.reference.RELIABLE_DESIGN`, the best
design the study found and the one the figures are drawn from.
Returns:
The design.
Raises:
SystemExit: If the reference cannot be resolved.
"""
from .reference import (
COUPLED_DESIGN,
RANGE_DESIGN,
REFINED_DESIGN,
RELIABLE_DESIGN,
)
named = {
"published": PUBLISHED_DESIGN,
"refined": REFINED_DESIGN,
"coupled": COUPLED_DESIGN,
"range": RANGE_DESIGN,
"reliable": RELIABLE_DESIGN,
}
if reference is None:
return RELIABLE_DESIGN
if reference in named:
return named[reference]
path = Path(reference)
if not path.is_file():
raise SystemExit(
f"no such design: {reference!r} (expected one of {NAMED_DESIGNS} or a JSON file)"
)
data = json.loads(path.read_text())
if isinstance(data, list):
return Design.from_array(data)
return Design(**{name: float(data[name]) for name in VARIABLE_NAMES})
[docs]
def save_design(design: Design, path: str | Path) -> Path:
"""Write a design to a JSON file."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {name: float(getattr(design, name)) for name in VARIABLE_NAMES}
path.write_text(json.dumps(payload, indent=2) + "\n")
return path
def _bounds_from(args: argparse.Namespace, design: Design | None) -> Bounds:
"""Pick the design box: global, or a window around a design."""
if getattr(args, "local", 0.0):
if design is None:
raise SystemExit("--local needs a --design to centre the box on")
return Bounds.around(design, relative=args.local)
return GLOBAL_BOUNDS
# -- commands --------------------------------------------------------------------
def _cmd_analyse(args: argparse.Namespace) -> int:
from .scenarios import format_analysis
design = load_design(args.design)
analysis = analyse(design, samples=args.samples)
print(format_analysis(analysis, title=f"design: {args.design or 'refined'}"))
if args.save:
print(f"\nwritten: {save_design(design, args.save)}")
return 0 if analysis.valid else 1
def _cmd_animate(args: argparse.Namespace) -> int:
from .animation import animate, animate_dashboard, animate_formulations, save
interval = int(1000 / args.fps)
if args.formulations:
# Every formulation's own result, so --design would have nothing to
# select; the designs come from exlink.reference.
animation = animate_formulations(frames=args.frames, interval=interval)
else:
design = load_design(args.design)
builder = animate if args.plain else animate_dashboard
animation = builder(design, frames=args.frames, interval=interval)
path = save(animation, args.output, fps=args.fps, dpi=args.dpi)
print(f"written: {path}")
return 0
def _cmd_plot(args: argparse.Namespace) -> int:
from . import plots
design = load_design(args.design)
analysis = analyse(design, samples=args.samples)
outdir = Path(args.outdir)
outdir.mkdir(parents=True, exist_ok=True)
figures = {
"motion": plots.plot_motion(design, analysis),
"cycle": plots.plot_cycle(design, analysis),
"torque": plots.plot_torque(design, analysis),
"mechanism": plots.plot_mechanism(design, analysis=analysis),
"variables": plots.plot_variables(design, analysis=analysis),
"overview": plots.plot_overview(design, analysis),
}
for name, figure in figures.items():
path = outdir / f"{name}.png"
figure.savefig(path, dpi=args.dpi, bbox_inches="tight")
print(f"written: {path}")
return 0
def _cmd_diagram(args: argparse.Namespace) -> int:
"""Write the N2 chart and the XDSM of the range problem."""
from .diagrams import build_published_scenario, write_n2, write_xdsm
outdir = Path(args.outdir)
scenario = build_published_scenario(design=load_design(args.design))
for path in (
write_n2(outdir / "n2.png", scenario=scenario),
write_xdsm(outdir, scenario=scenario, dpi=args.dpi),
):
print(f"written: {path}")
return 0
def _cmd_cycle(args: argparse.Namespace) -> int:
from .drivecycle import (
STANDARD_CYCLE,
format_cycle,
score_cycle,
score_slidercrank_cycle,
)
design = load_design(args.design)
kwargs: dict[str, Any] = {}
if args.module is not None:
kwargs["module"] = args.module
if args.teeth is not None:
kwargs["teeth"] = args.teeth
result = score_cycle(design, STANDARD_CYCLE, **kwargs)
print(format_cycle(result))
if args.baseline is not None:
from .slidercrank import SliderCrank
mechanism = SliderCrank.for_compression_ratio(args.ratio, args.baseline)
rival = score_slidercrank_cycle(mechanism, STANDARD_CYCLE)
print()
print(format_cycle(rival))
if rival.km_per_litre > 0.0:
gain = result.km_per_litre / rival.km_per_litre - 1.0
print(f"\n EX-link over the cycle: {gain:+.1%}")
return 0 if result.feasible else 1
def _cmd_optimize(args: argparse.Namespace) -> int:
from .scenarios import maximise_efficiency
start = load_design(args.design) if args.design else None
outcome = maximise_efficiency(
algorithm=args.algorithm,
bounds=_bounds_from(args, start),
initial=start,
max_iter=args.max_iter,
samples=args.samples,
max_height=args.max_height,
max_width=args.max_width,
seed=args.seed,
)
print(outcome.summary())
print(f"\nevaluations: {outcome.n_evaluations}")
if args.save:
print(f"written: {save_design(outcome.design, args.save)}")
return 0 if outcome.feasible else 1
def _cmd_refine(args: argparse.Namespace) -> int:
from .scenarios import refine
outcome = refine(
load_design(args.design),
algorithm=args.algorithm,
relative=args.relative,
max_iter=args.max_iter,
samples=args.samples,
)
print(outcome.summary())
print(f"\nevaluations: {outcome.n_evaluations}")
if args.save:
print(f"written: {save_design(outcome.design, args.save)}")
return 0 if outcome.feasible else 1
def _cmd_size(args: argparse.Namespace) -> int:
from .coupled import solve_for_design
from .scenarios import format_coupled
from .sections import Section
design = load_design(args.design)
result = solve_for_design(
design,
speed_rpm=args.rpm,
samples=args.samples,
max_iterations=args.max_iterations,
relaxation=args.relaxation,
section=Section(bore_ratio=args.bore, min_wall=args.min_wall),
)
crankshaft = DEFAULT_SPEC.output_speed_rpm(args.rpm)
print(format_coupled(result, f"sizing at {crankshaft:.0f} rpm"))
if not result.converged:
print("\nthe sizing loop did not converge: try --relaxation 0.5")
elif result.saturated:
print(
"\nsome member hit the diameter ceiling: at this speed the mechanism"
"\ncannot be built to carry the inertia its own mass creates."
)
if args.plot:
from .plots import plot_sizing
path = Path(args.plot)
path.parent.mkdir(parents=True, exist_ok=True)
plot_sizing(result).savefig(path, dpi=args.dpi, bbox_inches="tight")
print(f"\nwritten: {path}")
return 0 if result.feasible else 1
def _cmd_pareto(args: argparse.Namespace) -> int:
from .plots import plot_pareto
from .scenarios import local_pareto, pareto_front
start = load_design(args.design) if args.design else None
if args.local and start is not None:
outcome = local_pareto(
start,
relative=args.local,
algorithm=args.algorithm,
pop_size=args.pop_size,
max_gen=args.max_gen,
samples=args.samples,
seed=args.seed,
)
else:
outcome = pareto_front(
algorithm=args.algorithm,
initial=start,
pop_size=args.pop_size,
max_gen=args.max_gen,
samples=args.samples,
seed=args.seed,
)
print(f"front size: {len(outcome.front)} evaluations: {outcome.n_evaluations}")
print()
print(outcome.summary())
if outcome.front:
figure = plot_pareto(outcome.front, highlight=outcome.design, samples=args.samples)
path = Path(args.output)
path.parent.mkdir(parents=True, exist_ok=True)
figure.savefig(path, dpi=args.dpi, bbox_inches="tight")
print(f"\nwritten: {path}")
if args.save:
print(f"written: {save_design(outcome.design, args.save)}")
return 0 if outcome.front else 1
# -- parser ----------------------------------------------------------------------
[docs]
def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser."""
parser = argparse.ArgumentParser(
prog="exlink",
description=(
"Optimization of the EX-link Atkinson-cycle engine mechanism "
"of an extended-expansion (Atkinson) engine linkage."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="design variables:\n"
+ "\n".join(f" {n:<9} {VARIABLE_DESCRIPTIONS[n]}" for n in VARIABLE_NAMES),
)
parser.add_argument("-v", "--verbose", action="store_true", help="log GEMSEO output")
subparsers = parser.add_subparsers(dest="command", required=True)
def add_design(sub: argparse.ArgumentParser, default_help: str = "") -> None:
sub.add_argument(
"-d",
"--design",
default=None,
help=f"'published', 'refined' (default), or a JSON file{default_help}",
)
analyse_parser = subparsers.add_parser("analyse", help="print objectives and constraints")
add_design(analyse_parser)
analyse_parser.add_argument("--samples", type=int, default=720)
analyse_parser.add_argument("--save", default=None, help="write the design to JSON")
analyse_parser.set_defaults(func=_cmd_analyse)
animate_parser = subparsers.add_parser("animate", help="write a GIF or MP4")
add_design(animate_parser)
animate_parser.add_argument("-o", "--output", default="exlink.gif")
animate_parser.add_argument("--frames", type=int, default=180)
animate_parser.add_argument("--fps", type=int, default=25)
animate_parser.add_argument("--dpi", type=int, default=110)
animate_parser.add_argument(
"--plain", action="store_true", help="mechanism only, without the side plots"
)
animate_parser.add_argument(
"--formulations",
action="store_true",
help="each formulation's final design side by side (ignores --design)",
)
animate_parser.set_defaults(func=_cmd_animate)
plot_parser = subparsers.add_parser("plot", help="write the static figures")
add_design(plot_parser)
plot_parser.add_argument("-o", "--outdir", default="figures")
plot_parser.add_argument("--samples", type=int, default=720)
plot_parser.add_argument("--dpi", type=int, default=140)
plot_parser.set_defaults(func=_cmd_plot)
diagram_parser = subparsers.add_parser(
"diagram", help="write GEMSEO's N2 chart and XDSM of the coupled problem"
)
add_design(diagram_parser, " to build the scenario at")
diagram_parser.add_argument("-o", "--outdir", default="figures")
diagram_parser.add_argument(
"--dpi", type=int, default=RENDER_DPI, help="resolution for the rasterised XDSM"
)
diagram_parser.set_defaults(func=_cmd_diagram)
optimize_parser = subparsers.add_parser("optimize", help="maximise efficiency")
add_design(optimize_parser, " to start from")
optimize_parser.add_argument("-a", "--algorithm", default="DIFFERENTIAL_EVOLUTION")
optimize_parser.add_argument("--max-iter", type=int, default=4000, dest="max_iter")
optimize_parser.add_argument("--samples", type=int, default=360)
optimize_parser.add_argument(
"--max-height", type=float, default=float("inf"), dest="max_height"
)
optimize_parser.add_argument(
"--max-width", type=float, default=float("inf"), dest="max_width"
)
optimize_parser.add_argument(
"--local",
type=float,
default=0.0,
help="search a box of this relative half-width around --design",
)
optimize_parser.add_argument("--seed", type=int, default=1)
optimize_parser.add_argument("--save", default=None)
optimize_parser.set_defaults(func=_cmd_optimize)
refine_parser = subparsers.add_parser("refine", help="augmented Lagrangian polish")
add_design(refine_parser)
refine_parser.add_argument("-a", "--algorithm", default="Augmented_Lagrangian_order_0")
refine_parser.add_argument("--relative", type=float, default=0.25)
refine_parser.add_argument("--max-iter", type=int, default=200, dest="max_iter")
refine_parser.add_argument("--samples", type=int, default=720)
refine_parser.add_argument("--save", default=None)
refine_parser.set_defaults(func=_cmd_refine)
pareto_parser = subparsers.add_parser("pareto", help="approximate the Pareto front")
add_design(pareto_parser, " to seed a local front")
pareto_parser.add_argument("-a", "--algorithm", default="PYMOO_NSGA2")
pareto_parser.add_argument("--pop-size", type=int, default=200, dest="pop_size")
pareto_parser.add_argument("--max-gen", type=int, default=60, dest="max_gen")
pareto_parser.add_argument("--samples", type=int, default=360)
pareto_parser.add_argument(
"--local",
type=float,
default=0.0,
help="run in a box of this relative half-width around --design",
)
pareto_parser.add_argument("--seed", type=int, default=1)
pareto_parser.add_argument("-o", "--output", default="figures/pareto.png")
pareto_parser.add_argument("--dpi", type=int, default=140)
pareto_parser.add_argument("--save", default=None)
pareto_parser.set_defaults(func=_cmd_pareto)
cycle_parser = subparsers.add_parser(
"cycle", help="score a design over a schedule of speeds"
)
add_design(cycle_parser)
cycle_parser.add_argument(
"--module", type=float, default=None, help="pin the gear module [mm]"
)
cycle_parser.add_argument(
"--teeth", type=int, default=None, help="pin the driven gear's tooth count"
)
cycle_parser.add_argument(
"--baseline",
type=float,
nargs="?",
const=0.195,
default=None,
help="also score a slider-crank at this obliquity r/l (default 0.195)",
)
cycle_parser.add_argument(
"--ratio", type=float, default=16.0, help="the baseline's compression ratio"
)
cycle_parser.set_defaults(func=_cmd_cycle)
size_parser = subparsers.add_parser(
"size", help="size the parts with inertia in the load path"
)
add_design(size_parser)
size_parser.add_argument(
"--rpm",
type=float,
default=DEFAULT_SPEED_RPM,
help=(
"analysis speed (the half-speed shaft); the crankshaft turns twice "
"as fast, and it is the crankshaft speed that is reported"
),
)
size_parser.add_argument("--samples", type=int, default=180)
size_parser.add_argument("--max-iterations", type=int, default=400, dest="max_iterations")
size_parser.add_argument(
"--relaxation",
type=float,
default=1.0,
help="under-relaxation in (0, 1]; below 1 damps a stiff loop",
)
size_parser.add_argument(
"--bore",
type=float,
default=0.0,
help="bore ratio d_i/d_o of the rods and the trigonal link; 0 is solid bar",
)
size_parser.add_argument(
"--min-wall",
type=float,
default=DEFAULT_MIN_WALL,
dest="min_wall",
help="thinnest wall a bored member may be drawn with [mm]",
)
size_parser.add_argument("--plot", default=None, help="write a sizing figure here")
size_parser.add_argument("--dpi", type=int, default=140)
size_parser.set_defaults(func=_cmd_size)
return parser
[docs]
def main(argv: Sequence[str] | None = None) -> int:
"""Entry point for the ``exlink`` console script."""
parser = build_parser()
args = parser.parse_args(argv)
configure_logging(args.verbose)
handler: Any = args.func
return int(handler(args))
if __name__ == "__main__": # pragma: no cover
sys.exit(main())