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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ Generate `_cso_bindings` from the authored functions before importing them;
define, generate, import and call workflow. The
[section-property comparisons](examples/section-properties/README.md#reuse-in-a-comparison)
apply it to larger calculations while retaining their documented intermediates.
The [cylindrical tank estimate](examples/cylinder-tank/README.md) uses the same
method for unit conversion, integer counts, conditional fill, plan offsets, and
a repeated shell comparison.

## Use Python's scientific libraries

Expand Down
123 changes: 123 additions & 0 deletions examples/cylinder-tank/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Cylindrical tank estimate

These calculations estimate the steel in an open vertical cylinder, the liquid it
contains, and the plan position of its centre. They are a material and contents
estimate, not a structural capacity calculation. Human engineering approval is
not claimed.

The sources are separate calculation shapes. Each one exercises a rule from
[authoring](../../docs/authoring.md) that the other maintained examples do not
combine in a small project.

| Shape | Source | What is different |
| --- | --- | --- |
| Shared metadata | [quantities.py](quantities.py) | Float and int aliases for glyphs, descriptions, and units |
| Reusable circle in a nested path | [geometry/circle.cso.py](geometry/circle.cso.py) | `math.pi`, a hidden radius, and qualified glyphs. The directory name is a Python identifier |
| Explicit unit conversion | [thickness.cso.py](thickness.cso.py) | Millimetres become metres in their own calculation. Callers do not convert by ignoring the unit |
| Integer counts | [courses.cso.py](courses.cso.py) | `int` inputs and `int` results, including `math.ceil` and `max`. The maximum course height is a callee default |
| Conditional fill | [contents.cso.py](contents.cso.py) | A comparison chain selects empty, partial, or full liquid height. Only the selected branch is evaluated |
| Notation scopes and trigonometry | [position.cso.py](position.cso.py) | `c_{off}` is kept in `x-axis` and `y-axis` scopes. Degrees are converted with `math.radians` before `cos` and `sin`; `hypot` and `atan2` recover the radius and bearing |
| Composition and a hidden intermediate | [shell.cso.py](shell.cso.py) | Calls the circle, conversion, and course calculations. A `document_section` groups the plate formulas. Developed area is documented and is not a public output |
| Parent estimate | [estimate.cso.py](estimate.cso.py) | Forwards the inside area into the contents calculation, so that quantity keeps its identity. Adds assumptions, a notation legend, and [tank.svg](tank.svg) |
| Repeated calls | [compare.cso.py](compare.cso.py) | Calls the shell twice. `baseline_shell` and `candidate_shell` qualify repeated glyphs. The parent returns the masses and the ratio; the child formulas stay in the document |
| Reference-glyph reproduction | [references/published_circle.cso.py](references/published_circle.cso.py) | The tank does not call this file. It preserves the published glyphs `d`, `r`, `C`, and `A` and records the reference URL |

## Generate and verify

Run from the repository root after [setup](../../docs/development.md#setup).
`PYTHON` selects the interpreter that contains the installed `cs-object` wheel.

```sh
"$PYTHON" -m cso_python bindings examples/cylinder-tank
node packages/cso-cli/dist/cli.js bindings examples/cylinder-tank --check
node packages/cso-cli/dist/cli.js verify examples/cylinder-tank/estimate.cso.py --function estimate --reference examples/cylinder-tank/reference.json --format json
node packages/cso-cli/dist/cli.js verify examples/cylinder-tank/estimate.cso.py --function estimate --input fill_ratio=0 --reference examples/cylinder-tank/reference.json --format json
node packages/cso-cli/dist/cli.js verify examples/cylinder-tank/estimate.cso.py --function estimate --input fill_ratio=1 --reference examples/cylinder-tank/reference.json --format json
node packages/cso-cli/dist/cli.js verify examples/cylinder-tank/estimate.cso.py --function estimate --input bearing_degrees=90 --reference examples/cylinder-tank/reference.json --format json
node packages/cso-cli/dist/cli.js verify examples/cylinder-tank/estimate.cso.py --function estimate --input course_count=3 --reference examples/cylinder-tank/reference.json --format json
node packages/cso-cli/dist/cli.js verify examples/cylinder-tank/compare.cso.py --function compare_shells --reference examples/cylinder-tank/reference.json --format json
node packages/cso-cli/dist/cli.js verify examples/cylinder-tank/compare.cso.py --function compare_shells --input candidate_diameter=2 --reference examples/cylinder-tank/reference.json --format json
node packages/cso-cli/dist/cli.js verify examples/cylinder-tank/references/published_circle.cso.py --function published_circle --reference examples/cylinder-tank/reference.json --format json
```

For a plain Python consumer, use this directory as the working folder:

```sh
(cd examples/cylinder-tank && "$PYTHON" - <<'PY'
import math
from _cso_bindings.estimate import estimate
from _cso_bindings.compare import compare_shells

result = estimate()
assert result["area"] == math.pi
assert result["bolt_count"] == 48
assert result["radial_offset"] == 5.0
assert compare_shells()["mass_ratio"] == 1.25
assert compare_shells(candidate_diameter=2)["mass_ratio"] == 1.0
print(result)
PY
)
```

Successful execution does not establish source-to-document consistency. `verify`
does. Passing `--reference` adds independent numerical agreement. HTML and PDF
checks add content retention, and PDF adds rendering. Visual inspection of every
page is still separate. See [rendering](../../docs/rendering.md#choose-verification-by-change).

## Expected results

The default estimate uses diameter 2 m, height 6 m, wall 8 mm, steel density
7850 kg/m^3, 4 courses, 12 bolts per course, fill ratio 0.5, liquid density
1000 kg/m^3, plan radius 5 m, and bearing 0 degrees. The maximum course height
stays at its callee default of 1.5 m.

| Quantity | Result |
| --- | --- |
| Inside radius, area, circumference | 1 m, pi m^2, 2 pi m |
| Wall thickness | 0.008 m |
| Developed area, shell volume, shell mass | 12 pi m^2, 0.096 pi m^3, 753.6 pi kg |
| Course height, required courses, bolts, shortfall | 1.5 m, 4, 48, 0 |
| Liquid height, volume, mass, freeboard | 3 m, 3 pi m^3, 3000 pi kg, 3 m |
| East, north, radial offset, recovered bearing | 5 m, 0 m, 5 m, 0 degrees |

`pi` here is Python's `math.pi`. Products that are not exact decimals are the
binary64 results of the authored operation order. At these defaults the shell
mass is 2367.504223745268 kg and the liquid mass is 9424.77796076938 kg.

Fill ratio 0 selects the empty branch: liquid height, volume, and mass are 0,
and freeboard is 6 m. Fill ratio 1 selects the full branch: liquid height is
6 m, liquid volume is 6 pi m^3, liquid mass is 6000 pi kg, and freeboard is 0.
Bearing 90 degrees gives a north component of 5 m, a radial offset of 5 m, and
a recovered bearing of 90 degrees. The east component is the binary64 cosine of
a right angle, about 3.06e-16 m, not a mathematical zero. Three courses give a
course height of 2 m, 36 bolts, and a shortfall of 1.

The default comparison uses diameters 2 m and 2.5 m. Shell mass is proportional
to diameter, and the candidate-to-baseline ratio is 1.25. Equal diameters give
a ratio of 1. The published circle at diameter 2 m has radius 1 m, circumference
2 pi m, and area pi m^2.

[reference.json](reference.json) binds these values to the source bytes, the
selected function, and the resolved inputs. Formula-only edits leave generated
handles current and make this file stale. Review the formulas and source hashes
before rebinding it. Do not copy expected numbers from the execution being
checked.

## Notation

Python names stay descriptive. Display glyphs are compact, and the estimate
legend defines them: tank, circ, wall, mm, stl, liq, crs, bolt, req, short,
free, plan, bear, rad, off, rec, dev, and shl.

`c_{off}` is one glyph in two scopes. The x-axis scope is the easting and the
y-axis scope is the northing. The diagram uses `D_{tank}`, `h_{tank}`, and
`t_{wall,mm}`.

The comparison qualifies repeated results. `baseline_shell` and
`candidate_shell` become `bs` and `cs`. Nested `base_circle`, `wall_thickness`,
and `course_bolts` become `bc`, `wt`, and `cb`. The baseline inside area is
`A_{circ,bs,bc}` and the baseline shell mass is `m_{shl,bs}`. Equal inputs do
not merge the two shells.

The published circle is the exception that keeps bare reference glyphs. The tank
sources do not use those glyphs.
65 changes: 65 additions & 0 deletions examples/cylinder-tank/compare.cso.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Compare two cylindrical shells that share every input except diameter."""

from typing import Annotated

from _cso_bindings.shell import shell
from cso_python import CalculationResults, calculation, section, symbol, text
from quantities import (
BaselineDiameter,
BoltsPerCourse,
CandidateDiameter,
CourseCount,
SteelDensity,
TankHeight,
WallThicknessMillimetres,
)


@calculation(id="compare-shells", title="Compare cylindrical shells")
@section(id="comparison", title="Shell comparison", root=True)
def compare_shells(
baseline_diameter: BaselineDiameter = 2.0,
candidate_diameter: CandidateDiameter = 2.5,
height: TankHeight = 6.0,
thickness_mm: WallThicknessMillimetres = 8.0,
density: SteelDensity = 7850.0,
course_count: CourseCount = 4,
bolts_per_course: BoltsPerCourse = 12,
) -> CalculationResults:
text(
id="scope",
content="Both shells use the same height, wall thickness, density, and course schedule. Only the inside diameter changes. The mass ratio describes that geometry. It does not establish capacity or compliance. Baseline diameter, height, thickness, and density must be positive.",
)
text(
id="notation",
content="Subscripts base and cand identify the input diameters. Result qualifiers bs and cs identify baseline_shell and candidate_shell. Nested qualifiers bc, wt, and cb identify base_circle, wall_thickness, and course_bolts.",
)
baseline_shell = shell(
diameter=baseline_diameter,
height=height,
thickness_mm=thickness_mm,
density=density,
course_count=course_count,
bolts_per_course=bolts_per_course,
)
candidate_shell = shell(
diameter=candidate_diameter,
height=height,
thickness_mm=thickness_mm,
density=density,
course_count=course_count,
bolts_per_course=bolts_per_course,
)
mass_ratio: Annotated[
float,
symbol(
glyph=r"R_{mass}",
description="Candidate-to-baseline shell mass ratio",
unit="",
),
] = candidate_shell["shell_mass"] / baseline_shell["shell_mass"]
return {
"baseline_mass": baseline_shell["shell_mass"],
"candidate_mass": candidate_shell["shell_mass"],
"mass_ratio": mass_ratio,
}
47 changes: 47 additions & 0 deletions examples/cylinder-tank/contents.cso.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Liquid contents of a vertical cylindrical tank."""

from typing import Annotated

from cso_python import CalculationResults, calculation, section, symbol, text
from quantities import CircleArea, FillRatio, LiquidDensity, TankHeight


@calculation(id="tank-contents", title="Tank contents")
@section(id="contents", title="Liquid contents")
def contents(
base_area: CircleArea,
height: TankHeight,
fill_ratio: FillRatio = 0.5,
liquid_density: LiquidDensity = 1000.0,
) -> CalculationResults:
text(
id="fill",
content="A fill ratio at or below zero is empty, a ratio at or above one is full, and a ratio between them is a partial fill. liq = liquid; free = freeboard.",
)
liquid_height: Annotated[
float,
symbol(glyph=r"h_{liq}", description="Liquid height", unit="m"),
] = (
height * fill_ratio
if 0 < fill_ratio < 1
else height
if fill_ratio >= 1
else 0.0
)
liquid_volume: Annotated[
float,
symbol(glyph=r"V_{liq}", description="Liquid volume", unit="m^3"),
] = base_area * liquid_height
liquid_mass: Annotated[
float,
symbol(glyph=r"m_{liq}", description="Liquid mass", unit="kg"),
] = liquid_volume * liquid_density
freeboard: Annotated[
float,
symbol(glyph=r"h_{free}", description="Unfilled shell height", unit="m"),
] = max(height - liquid_height, 0.0)
return {
"liquid_volume": liquid_volume,
"liquid_mass": liquid_mass,
"freeboard": freeboard,
}
42 changes: 42 additions & 0 deletions examples/cylinder-tank/courses.cso.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Integer course and bolt counts for a cylindrical shell."""

import math
from typing import Annotated

from cso_python import CalculationResults, calculation, section, symbol
from quantities import BoltsPerCourse, CourseCount, MaximumCourseHeight, TankHeight


@calculation(id="shell-courses", title="Shell course schedule")
@section(id="courses", title="Shell courses")
def shell_courses(
course_count: CourseCount,
bolts_per_course: BoltsPerCourse,
height: TankHeight,
maximum_course_height: MaximumCourseHeight = 1.5,
) -> CalculationResults:
required_course_count: Annotated[
int,
symbol(
glyph=r"n_{req}",
description="Courses required by the maximum course height",
unit="",
),
] = math.ceil(height / maximum_course_height)
bolt_count: Annotated[
int,
symbol(glyph=r"n_{bolt}", description="Bolts in the shell", unit=""),
] = course_count * bolts_per_course
course_shortfall: Annotated[
int,
symbol(
glyph=r"n_{short}",
description="Extra courses needed to respect the maximum course height",
unit="",
),
] = max(required_course_count - course_count, 0)
return {
"required_course_count": required_course_count,
"bolt_count": bolt_count,
"course_shortfall": course_shortfall,
}
73 changes: 73 additions & 0 deletions examples/cylinder-tank/estimate.cso.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Cylindrical tank estimate: shell steel, liquid contents, and plan position."""

from _cso_bindings.contents import contents
from _cso_bindings.position import plan_offset
from _cso_bindings.shell import shell
from cso_python import CalculationResults, calculation, figure, section, text
from quantities import (
BearingDegrees,
BoltsPerCourse,
CourseCount,
FillRatio,
LiquidDensity,
PlanRadius,
SteelDensity,
TankDiameter,
TankHeight,
WallThicknessMillimetres,
)


@calculation(id="cylinder-tank", title="Cylindrical tank estimate")
@section(id="summary", title="Inputs and totals", root=True)
def estimate(
diameter: TankDiameter = 2.0,
height: TankHeight = 6.0,
thickness_mm: WallThicknessMillimetres = 8.0,
density: SteelDensity = 7850.0,
course_count: CourseCount = 4,
bolts_per_course: BoltsPerCourse = 12,
fill_ratio: FillRatio = 0.5,
liquid_density: LiquidDensity = 1000.0,
plan_radius: PlanRadius = 5.0,
bearing_degrees: BearingDegrees = 0.0,
) -> CalculationResults:
text(
id="assumptions",
content="Open vertical cylinder with a uniform wall. Roof and floor plates, openings, corrosion allowance, and waste are excluded. Liquid volume uses the inside base area and the liquid height. This is a material and contents estimate, not a structural capacity calculation.",
)
text(
id="notation",
content="Subscripts: tank = tank; circ = circle; wall = wall; mm = millimetres; stl = steel; liq = liquid; crs = course; bolt = bolt; req = required; short = shortfall; free = freeboard; plan = plan; bear = bearing; rad = radians; off = offset; rec = recovered; dev = developed; shl = shell. The centre offset c_off is shared: x-axis is easting and y-axis is northing. Circle formulas follow https://en.wikipedia.org/wiki/Circle with these qualified glyphs.",
)
figure(
id="tank",
path="tank.svg",
media_type="image/svg+xml",
caption="Vertical cylindrical tank with inside diameter D_tank, shell height h_tank, and wall thickness t_wall,mm.",
alt="Vertical cylinder marked with inside diameter D_tank, shell height h_tank, and wall thickness t_wall,mm.",
)

shell_quantities = shell(
diameter=diameter,
height=height,
thickness_mm=thickness_mm,
density=density,
course_count=course_count,
bolts_per_course=bolts_per_course,
)
liquid = contents(
base_area=shell_quantities["area"],
height=height,
fill_ratio=fill_ratio,
liquid_density=liquid_density,
)
centre = plan_offset(plan_radius=plan_radius, bearing_degrees=bearing_degrees)

return {
"area": shell_quantities["area"],
"shell_mass": shell_quantities["shell_mass"],
"liquid_mass": liquid["liquid_mass"],
"bolt_count": shell_quantities["bolt_count"],
"radial_offset": centre["radial_offset"],
}
26 changes: 26 additions & 0 deletions examples/cylinder-tank/geometry/circle.cso.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Inside circle of a vertical cylindrical tank."""

import math
from typing import Annotated

from cso_python import CalculationResults, calculation, section, symbol
from quantities import CircleArea, CircleCircumference, TankDiameter


@calculation(
id="tank-circle",
title="Tank base circle",
metadata={
"formulaSource": "https://en.wikipedia.org/wiki/Circle",
"notation": "Qualified glyphs for a new calculation. circ means circle.",
},
)
@section(id="circle", title="Base circle")
def circle(diameter: TankDiameter) -> CalculationResults:
radius: Annotated[
float,
symbol(glyph=r"r_{circ}", description="Inside radius", unit="m"),
] = diameter / 2
area: CircleArea = math.pi * radius**2
circumference: CircleCircumference = math.pi * diameter
return {"area": area, "circumference": circumference}
Loading
Loading