mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
Prove the dose curve and the recorder; document S-means-light
The stream harness gains rule 19: with the bench-default curve in force a ladder of S rungs renders the curve's densities exactly (half light lands near 80 percent density), monotonic, floored and ceiled by $35/$36; every other session pins laser_dose_curve = off so its S-to-level arithmetic stays exact, and the arm-report checks carry the curve name. The lifecycle harness asserts the published state file names the curve in force. The catalog's forgectrl.panel-serves asserts the curve field in the grbl report, /curve/status and the ladder G-code, and its covers name the recorder. BRINGUP describes S-commands-light through the measured curve and the owner recorder; the MOTION table gains laser_dose_curve; LIGHTBURN tells the operator power now means light and how to record their own tube's curve from the panel.
This commit is contained in:
+20
-10
@@ -238,16 +238,26 @@ spot. The arm report names it (`laser armed (density, floor 10 %)`), and
|
|||||||
a floor of 0 is honored with a note (the ladders run that way). The
|
a floor of 0 is honored with a note (the ladders run that way). The
|
||||||
cooling report carries the model with the job state.
|
cooling report carries the model with the job state.
|
||||||
|
|
||||||
**Measured dose response (this bench, 2026-08-30, by the head thermopile,
|
**S commands light, through the measured curve.** The tube's output is
|
||||||
the tube current and the operator's eye on Thick Draftboard and acrylic).**
|
convex in pulse density (this bench, 2026-08-30, by the head thermopile,
|
||||||
Density delivers about half of the CW light at 80 % density, a third at
|
the tube current and the operator's eye: 80 % density delivers about
|
||||||
60 %, a fifth at 45 % and a fourteenth at 30 %: the curve is the tube's
|
half the CW light, 60 % a third, 45 % a fifth, 30 % a fourteenth - the
|
||||||
(pulsed against CW), not the sensor's, and it is the same physics behind
|
same physics behind the factory's 18.9 to 79.5 % mapping with Full
|
||||||
the factory's 18.9 to 79.5 % mapping with Full Power kept apart. An S
|
Power kept apart). So the driver maps the commanded fraction through
|
||||||
correction from that curve (E4 in the working file) is the open item that
|
the measured curve's inverse onto the density that delivers it:
|
||||||
follows. Rasters hold their tonality down to ~14 pulse slots per pixel
|
`laser_dose_curve` in the machine config holds density:light percent
|
||||||
(508 DPI at 6000 mm/min): the dither accumulator's cross-pixel averaging
|
pairs, ships with the bench-measured default compiled in, accepts
|
||||||
recovers the levels, with no visible dither pattern.
|
`off` for the identity, falls back loudly on a bad value, and is
|
||||||
|
reloaded at every precompute with the arm naming it (`laser armed
|
||||||
|
(density, floor 10 %, curve bench-default)`). `$35`/`$36` still floor
|
||||||
|
and ceil the result. An owner measures their own curve from the panel:
|
||||||
|
the dose-curve recorder hands them a ladder G-code to run from their
|
||||||
|
sender - every arm gate stands, forgectrl records the tube current and
|
||||||
|
the head thermopile passively, fits the rungs, and Apply writes the
|
||||||
|
result (`forgectrl/docs/SERVICES.md`). Rasters hold their tonality down
|
||||||
|
to ~14 pulse slots per pixel (508 DPI at 6000 mm/min): the dither
|
||||||
|
accumulator's cross-pixel averaging recovers the levels, with no
|
||||||
|
visible dither pattern.
|
||||||
|
|
||||||
An S word takes effect whether or not motion is in progress. Per-segment
|
An S word takes effect whether or not motion is in progress. Per-segment
|
||||||
updates carry the level inside a laser block, but an S executed between
|
updates carry the level inside a laser block, but an S executed between
|
||||||
|
|||||||
+10
-6
@@ -152,12 +152,16 @@ restart the controller with the head re-parked.)
|
|||||||
The controller drives the tube the way the factory does: every pulse
|
The controller drives the tube the way the factory does: every pulse
|
||||||
fires at full power, and the power setting decides how many ticks of
|
fires at full power, and the power setting decides how many ticks of
|
||||||
each 710 us period fire. Every power level marks, low levels included,
|
each 710 us period fire. Every power level marks, low levels included,
|
||||||
because no pulse is ever too weak to strike. The response is not linear:
|
because no pulse is ever too weak to strike. The raw response is not
|
||||||
on this machine 80 % gives about half the light of 100 %, 60 % about a
|
linear - 80 % of the pulses deliver about half the light - so the
|
||||||
third, 30 % about a fourteenth - so pick engrave power by test card, and
|
controller maps your power setting through a measured dose curve: 50 %
|
||||||
prefer setting darkness with speed. Grayscale images fade cleanly into
|
commands half the light, not half the pulses. The machine ships with
|
||||||
the shadows (a low level becomes sparse full-power pulses), and 254 to
|
the bench-measured curve; record your own tube's from the control
|
||||||
508 DPI rasters hold their tonal steps.
|
panel (GRBL tab, "Dose-curve recorder": download the ladder file, press
|
||||||
|
Record, run the file from LightBurn on scrap, press the button, Apply
|
||||||
|
the fit). Grayscale images fade cleanly into the shadows (a low level
|
||||||
|
becomes sparse full-power pulses), and 254 to 508 DPI rasters hold
|
||||||
|
their tonal steps.
|
||||||
|
|
||||||
`$35`, the power floor, is set by the controller from the machine config
|
`$35`, the power floor, is set by the controller from the machine config
|
||||||
(the control panel's GRBL tab, "Laser dose"): do not type it, it is
|
(the control panel's GRBL tab, "Laser dose"): do not type it, it is
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ only be changed while the machine is idle.
|
|||||||
| `laser_button_timeout_s` | 300 | How long the machine waits at the button prompt. |
|
| `laser_button_timeout_s` | 300 | How long the machine waits at the button prompt. |
|
||||||
| `laser_disarm_s` | 60 | Spindle-off grace before the armed window closes. |
|
| `laser_disarm_s` | 60 | Spindle-off grace before the armed window closes. |
|
||||||
| `laser_floor_density` | 10 | The S-range floor, percent of full: the lowest pulse density that still marks. Loaded into `$35` at every spindle precompute; `$35` is derived, never typed. |
|
| `laser_floor_density` | 10 | The S-range floor, percent of full: the lowest pulse density that still marks. Loaded into `$35` at every spindle precompute; `$35` is derived, never typed. |
|
||||||
|
| `laser_dose_curve` | (bench default) | The measured dose curve as density:light percent pairs; S commands a light fraction and the driver maps it onto the density that delivers it. `off` = identity; a bad value falls back to the default. The panel's recorder measures and applies a machine's own. |
|
||||||
| `laser_pulse_ticks` | 20 | Density base period in machine ticks (35.5 us each). |
|
| `laser_pulse_ticks` | 20 | Density base period in machine ticks (35.5 us each). |
|
||||||
| `laser_pulse_min_ticks` | 3 | Shortest density pulse in ticks; below it a period is skipped and its debt carried. |
|
| `laser_pulse_min_ticks` | 3 | Shortest density pulse in ticks; below it a period is skipped and its debt carried. |
|
||||||
| `rail_settle_s` | 2.5 | Motor-rail off period when a controller takes the device standalone. |
|
| `rail_settle_s` | 2.5 | Motor-rail off period when a controller takes the device standalone. |
|
||||||
|
|||||||
@@ -222,7 +222,8 @@ def settings_bounds(ctx):
|
|||||||
kind="auto", est_min=1,
|
kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
|
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
|
||||||
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
|
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
|
||||||
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c")],
|
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
|
||||||
|
("forgectrl", "src/curverec.c")],
|
||||||
description="The panel page is served, /status carries the machine telemetry the panel and "
|
description="The panel page is served, /status carries the machine telemetry the panel and "
|
||||||
"the acceptance tool read (including the sys block: CPU busy percent over the "
|
"the acceptance tool read (including the sys block: CPU busy percent over the "
|
||||||
"interval since the previous read, memory used percent), and /cam/status "
|
"interval since the previous read, memory used percent), and /cam/status "
|
||||||
@@ -289,9 +290,21 @@ def panel_serves(ctx):
|
|||||||
ctx.check(key in rep, "/status grbl.report lacks %r", key)
|
ctx.check(key in rep, "/status grbl.report lacks %r", key)
|
||||||
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
|
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
|
||||||
"grbl.report.laser carries no model: %s", rep.get("laser"))
|
"grbl.report.laser carries no model: %s", rep.get("laser"))
|
||||||
|
ctx.check((rep.get("laser") or {}).get("curve"),
|
||||||
|
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
|
||||||
st, text = fc.get("/grbl/settings", raw=True)
|
st, text = fc.get("/grbl/settings", raw=True)
|
||||||
ev["grbl_settings_status"] = st
|
ev["grbl_settings_status"] = st
|
||||||
ctx.check(st == 200 and b"$35=" in (text or b""),
|
ctx.check(st == 200 and b"$35=" in (text or b""),
|
||||||
"GET /grbl/settings -> %s without the $$ view", st)
|
"GET /grbl/settings -> %s without the $$ view", st)
|
||||||
else:
|
else:
|
||||||
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
|
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
|
||||||
|
|
||||||
|
# The dose-curve recorder's read surface answers in any mode.
|
||||||
|
st, cs = fc.get("/curve/status")
|
||||||
|
ev["curve_status"] = cs
|
||||||
|
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
|
||||||
|
("idle", "waiting", "recording", "done", "failed"),
|
||||||
|
"GET /curve/status -> %s %s", st, cs)
|
||||||
|
st, text = fc.get("/curve/ladder.gcode", raw=True)
|
||||||
|
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
|
||||||
|
"GET /curve/ladder.gcode -> %s without the ladder", st)
|
||||||
|
|||||||
@@ -296,6 +296,8 @@ def test_status_files():
|
|||||||
assert '"armed":false' in st, "armed before any job: %r" % st
|
assert '"armed":false' in st, "armed before any job: %r" % st
|
||||||
assert '"model":"density"' in st and '"floor_pct":10' in st, \
|
assert '"model":"density"' in st and '"floor_pct":10' in st, \
|
||||||
"model/floor missing (the floor must be derived from boot): %r" % st
|
"model/floor missing (the floor must be derived from boot): %r" % st
|
||||||
|
assert '"curve":"bench-default"' in st, \
|
||||||
|
"the curve in force is not published: %r" % st
|
||||||
assert '"modals":"[GC:' in st, "modal report missing: %r" % st
|
assert '"modals":"[GC:' in st, "modal report missing: %r" % st
|
||||||
ts0 = float(st.split('"ts_mono":')[1].split(',')[0])
|
ts0 = float(st.split('"ts_mono":')[1].split(',')[0])
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,13 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
|||||||
18. the floor is derived, never typed: $35 is loaded from the floor
|
18. the floor is derived, never typed: $35 is loaded from the floor
|
||||||
key at every precompute, so a $35 typed by the sender is
|
key at every precompute, so a $35 typed by the sender is
|
||||||
overwritten - the ladder renders through the key's floor, and the
|
overwritten - the ladder renders through the key's floor, and the
|
||||||
arm report names the model and the floor in force
|
arm report names the model, the floor and the curve in force
|
||||||
|
19. the dose curve bends S onto the density that delivers the
|
||||||
|
commanded light fraction: with the bench-default curve in force a
|
||||||
|
ladder of S rungs renders the curve's densities (half light lands
|
||||||
|
near 80 percent density), monotonic, floored and ceiled by
|
||||||
|
$35/$36; every other session runs with laser_dose_curve = off so
|
||||||
|
its S-to-level arithmetic stays exact
|
||||||
|
|
||||||
The analog sessions select the reference mode through the config; on
|
The analog sessions select the reference mode through the config; on
|
||||||
hardware the controller ignores it (density is the only product model -
|
hardware the controller ignores it (density is the only product model -
|
||||||
@@ -169,6 +175,7 @@ JOB_LADDER.append("M5")
|
|||||||
# tube's lasing duty (16), covered by the switch sessions below.
|
# tube's lasing duty (16), covered by the switch sessions below.
|
||||||
ANALOG_FLOOR_DEFAULT_PCT = 16.0
|
ANALOG_FLOOR_DEFAULT_PCT = 16.0
|
||||||
ANALOG_CONF = ("laser_power_model = analog\n"
|
ANALOG_CONF = ("laser_power_model = analog\n"
|
||||||
|
"laser_dose_curve = off\n"
|
||||||
"laser_floor_analog = %g\n" % PWM_MIN_PCT)
|
"laser_floor_analog = %g\n" % PWM_MIN_PCT)
|
||||||
DENSITY_PERIOD = 20
|
DENSITY_PERIOD = 20
|
||||||
DENSITY_MIN_TICKS = 3
|
DENSITY_MIN_TICKS = 3
|
||||||
@@ -179,9 +186,43 @@ DENSITY_CONF_BASE = ("laser_pulse_ticks = %d\n"
|
|||||||
# analog duty out of the tube's dead band, and here it would just clamp
|
# analog duty out of the tube's dead band, and here it would just clamp
|
||||||
# the light end of the range. A floor of 0 is honored as written.
|
# the light end of the range. A floor of 0 is honored as written.
|
||||||
DENSITY_CONF = ("laser_power_model = density\n"
|
DENSITY_CONF = ("laser_power_model = density\n"
|
||||||
|
"laser_dose_curve = off\n"
|
||||||
"laser_floor_density = 0\n" + DENSITY_CONF_BASE)
|
"laser_floor_density = 0\n" + DENSITY_CONF_BASE)
|
||||||
# The shipped density default: no floor key, so the board's floor applies.
|
# The shipped density default: no floor key, so the board's floor applies.
|
||||||
DENSITY_CONF_FLOORED = "laser_power_model = density\n" + DENSITY_CONF_BASE
|
DENSITY_CONF_FLOORED = ("laser_power_model = density\n"
|
||||||
|
"laser_dose_curve = off\n" + DENSITY_CONF_BASE)
|
||||||
|
# The shipped default: the bench curve in force (no keys at all).
|
||||||
|
DENSITY_CONF_CURVED = "laser_power_model = density\n" + DENSITY_CONF_BASE
|
||||||
|
|
||||||
|
# The compiled bench-default curve (glowforge_laser.c curve_default),
|
||||||
|
# mirrored here the way the floor is: changing it changes rule 19.
|
||||||
|
CURVE_DEFAULT = ((10.0, 0.5), (20.0, 2.0), (30.0, 7.0), (45.0, 21.0),
|
||||||
|
(60.0, 37.0), (80.0, 50.0), (100.0, 100.0))
|
||||||
|
|
||||||
|
|
||||||
|
def curve_density_for(s_val):
|
||||||
|
"""The density fraction the bench-default curve maps an S onto,
|
||||||
|
before the $35/$36 clamp (mirrors curve_apply)."""
|
||||||
|
l = s_val / RPM_MAX * 100.0
|
||||||
|
pts = CURVE_DEFAULT
|
||||||
|
if l <= pts[0][1]:
|
||||||
|
return pts[0][0] * (l / pts[0][1]) / 100.0
|
||||||
|
i = 1
|
||||||
|
while i < len(pts) - 1 and l > pts[i][1]:
|
||||||
|
i += 1
|
||||||
|
d0, l0 = pts[i - 1]
|
||||||
|
d1, l1 = pts[i]
|
||||||
|
f = min(1.0, (l - l0) / (l1 - l0))
|
||||||
|
return (d0 + f * (d1 - d0)) / 100.0
|
||||||
|
|
||||||
|
|
||||||
|
CURVE_S = (100, 300, 500, 800, 1000)
|
||||||
|
JOB_CURVE = ["G91", "G21", "M3"]
|
||||||
|
for _s in CURVE_S:
|
||||||
|
JOB_CURVE.append("S%d" % _s)
|
||||||
|
JOB_CURVE.append("G1 X%g F300" % (LADDER_MM if _s % 2 == 0 else LADDER_MM))
|
||||||
|
JOB_CURVE.append("G0 Y1")
|
||||||
|
JOB_CURVE.append("M5")
|
||||||
DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S)
|
DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S)
|
||||||
# A $35 typed ahead of the job: rule 18 says the arm overwrites it.
|
# A $35 typed ahead of the job: rule 18 says the arm overwrites it.
|
||||||
JOB_DENSITY = ["$35=0"] + JOB_LADDER
|
JOB_DENSITY = ["$35=0"] + JOB_LADDER
|
||||||
@@ -193,6 +234,73 @@ def duty_for_floor(s, floor_pct):
|
|||||||
return int(s * (PWM_PERIOD - lo) / RPM_MAX) + lo
|
return int(s * (PWM_PERIOD - lo) / RPM_MAX) + lo
|
||||||
|
|
||||||
|
|
||||||
|
# Session H: three levels inside one kernel run. The moves are short and
|
||||||
|
# fast so the planner never drains, and each carries its own S word, so
|
||||||
|
# the level changes land mid-run. Analog pays a power byte per level;
|
||||||
|
# density pays none, because the level rides the FIRE bits.
|
||||||
|
JOB_LEVELS = ["G91", "G21", "M3"]
|
||||||
|
for _s in (100, 300, 600):
|
||||||
|
for _ in range(20):
|
||||||
|
JOB_LEVELS.append("G1 X0.5 F3000 S%d" % _s)
|
||||||
|
JOB_LEVELS.append("M5")
|
||||||
|
|
||||||
|
|
||||||
|
# Session I: the levels arrive on their own lines, and the moves are long
|
||||||
|
# enough that the planner drains between them, so each S is executed with
|
||||||
|
# nothing streaming. The state has no event to ride and must be
|
||||||
|
# re-asserted at the next run's first byte.
|
||||||
|
IDLE_S_LEVELS = (100, 300, 600)
|
||||||
|
IDLE_S_MM = 5.0
|
||||||
|
IDLE_S_FEED = 300
|
||||||
|
JOB_IDLE_S = ["G91", "G21", "M3"]
|
||||||
|
for _i, _s in enumerate(IDLE_S_LEVELS):
|
||||||
|
JOB_IDLE_S.append("S%d" % _s)
|
||||||
|
JOB_IDLE_S.append("G1 X%g F%d" % (IDLE_S_MM if _i % 2 == 0 else -IDLE_S_MM,
|
||||||
|
IDLE_S_FEED))
|
||||||
|
JOB_IDLE_S.append("M5")
|
||||||
|
|
||||||
|
|
||||||
|
# Session J: the bench ladder's shape. M5 executes with the planner
|
||||||
|
# drained and the kernel run over, and the rapids that follow start a
|
||||||
|
# new run; the core issues no per-segment laser update for moves made
|
||||||
|
# with the spindle off, so the stream's wanted state is all that decides
|
||||||
|
# whether those rapids fire. A bare G0 with no M3 since the M5 is the
|
||||||
|
# same case one step further.
|
||||||
|
M5_IDLE_MM = 5.0
|
||||||
|
M5_IDLE_FEED = 600
|
||||||
|
M5_IDLE_TICKS = M5_IDLE_MM / (M5_IDLE_FEED / 60.0) * 28160
|
||||||
|
JOB_M5_IDLE = [
|
||||||
|
"G91", "G21",
|
||||||
|
"M3 S500",
|
||||||
|
"G1 X%g F%d" % (M5_IDLE_MM, M5_IDLE_FEED),
|
||||||
|
WAIT_IDLE, ("sleep", 0.5),
|
||||||
|
"M5", ("sleep", 0.5),
|
||||||
|
"G0 X%g" % -M5_IDLE_MM, "G0 Y1",
|
||||||
|
WAIT_IDLE,
|
||||||
|
"G0 X%g" % M5_IDLE_MM,
|
||||||
|
WAIT_IDLE,
|
||||||
|
"M3 S500",
|
||||||
|
"G1 X%g" % -M5_IDLE_MM,
|
||||||
|
WAIT_IDLE, ("sleep", 0.5),
|
||||||
|
"M5",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Session K: two jobs in one controller process, the second at the level
|
||||||
|
# the first ended at. M2 leaves S modal and resets the motion mode to G1,
|
||||||
|
# so the next job's M3 executes at that S; the core records it and issues
|
||||||
|
# no per-segment update for a G1 at the same level, so the set_state is
|
||||||
|
# the only thing that can light it. The parser starts in G0, which is why
|
||||||
|
# a process's FIRST job never shows this: its M3 runs at rpm 0.
|
||||||
|
JOB_NEXT = [
|
||||||
|
"G91", "G21", "M3", "S500",
|
||||||
|
"G1 X%g F%d" % (M5_IDLE_MM, M5_IDLE_FEED),
|
||||||
|
WAIT_IDLE, ("sleep", 0.5),
|
||||||
|
"M5", "G0 X%g" % -M5_IDLE_MM, "G0 Y1",
|
||||||
|
WAIT_IDLE, "G90", "M2", ("sleep", 1.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def fail(msg):
|
def fail(msg):
|
||||||
print("FAIL: %s" % msg)
|
print("FAIL: %s" % msg)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -636,7 +744,7 @@ def main():
|
|||||||
rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD,
|
rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD,
|
||||||
DENSITY_MIN_TICKS)
|
DENSITY_MIN_TICKS)
|
||||||
check_termination("density", dens)
|
check_termination("density", dens)
|
||||||
if "laser armed (density, floor 0 %)" not in run_session.text:
|
if "laser armed (density, floor 0 %, curve off)" not in run_session.text:
|
||||||
fail("[density] the arm did not select the density model at floor 0")
|
fail("[density] the arm did not select the density model at floor 0")
|
||||||
print("PASS [density]: %d bytes, %d power bytes all at full duty, "
|
print("PASS [density]: %d bytes, %d power bytes all at full duty, "
|
||||||
"level->density %s"
|
"level->density %s"
|
||||||
@@ -746,13 +854,41 @@ def main():
|
|||||||
expect_levels = tuple(duty_for(x) for x in LADDER_S)
|
expect_levels = tuple(duty_for(x) for x in LADDER_S)
|
||||||
check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD,
|
check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD,
|
||||||
DENSITY_MIN_TICKS)
|
DENSITY_MIN_TICKS)
|
||||||
if "laser armed (density, floor %g %%)" % PWM_MIN_PCT not in run_session.text:
|
if "laser armed (density, floor %g %%, curve off)" % PWM_MIN_PCT not in run_session.text:
|
||||||
fail("[floor-derived] the arm report does not name the derived floor "
|
fail("[floor-derived] the arm report does not name the derived floor "
|
||||||
"(text: %r)" % run_session.text[-400:])
|
"(text: %r)" % run_session.text[-400:])
|
||||||
print("PASS [floor-derived]: a typed $35=0 is overwritten at the arm; the "
|
print("PASS [floor-derived]: a typed $35=0 is overwritten at the arm; the "
|
||||||
"ladder renders through the %g %% floor key, levels %s"
|
"ladder renders through the %g %% floor key, levels %s"
|
||||||
% (PWM_MIN_PCT, list(expect_levels)))
|
% (PWM_MIN_PCT, list(expect_levels)))
|
||||||
|
|
||||||
|
# --- rule 19: the dose curve bends S onto delivered light -----------
|
||||||
|
cur = run_session("curve", JOB_CURVE, conf=DENSITY_CONF_CURVED)
|
||||||
|
if "curve bench-default" not in run_session.text:
|
||||||
|
fail("[curve] the arm does not name the bench-default curve (text: %r)"
|
||||||
|
% run_session.text[-300:])
|
||||||
|
floor_frac = PWM_MIN / float(PWM_PERIOD)
|
||||||
|
expect = []
|
||||||
|
for s_val in CURVE_S:
|
||||||
|
d = curve_density_for(s_val)
|
||||||
|
expect.append(min(1.0, max(d, floor_frac)))
|
||||||
|
ticks = tick_bytes(cur)
|
||||||
|
spans = fire_spans(ticks)
|
||||||
|
if len(spans) != len(CURVE_S):
|
||||||
|
fail("[curve] %d fire spans, expected %d" % (len(spans), len(CURVE_S)))
|
||||||
|
got = []
|
||||||
|
for (a, b) in spans:
|
||||||
|
seg = ticks[a:b]
|
||||||
|
got.append(sum(1 for t in seg if t & 0x10) / float(len(seg)))
|
||||||
|
for g, w, s_val in zip(got, expect, CURVE_S):
|
||||||
|
if abs(g - w) > max(0.012, w * 0.06):
|
||||||
|
fail("[curve] S%d rendered density %.4f, expected %.4f through the "
|
||||||
|
"bench-default curve" % (s_val, g, w))
|
||||||
|
if not all(b > a for a, b in zip(got, got[1:])):
|
||||||
|
fail("[curve] densities not monotonic: %s" % [round(g, 4) for g in got])
|
||||||
|
check_termination("curve", cur)
|
||||||
|
print("PASS [curve]: S %s -> densities %s through the bench-default curve "
|
||||||
|
"(floored at %.3f)" % (list(CURVE_S), [round(g, 3) for g in got], floor_frac))
|
||||||
|
|
||||||
print("PASS: all stream emission rules hold")
|
print("PASS: all stream emission rules hold")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user