Skip to content

Added JSON serialisation for Lambda functions#1695

Open
bennthomsen wants to merge 6 commits intoQCoDeS:mainfrom
bennthomsen:JSONserialisation
Open

Added JSON serialisation for Lambda functions#1695
bennthomsen wants to merge 6 commits intoQCoDeS:mainfrom
bennthomsen:JSONserialisation

Conversation

@bennthomsen
Copy link

Pull Request Summary

Title: Added JSON serialization for Lambda functions

Description

This PR adds support for serializing and deserializing BluePrint objects that contain arbitrary functions (arb_func), including lambda functions and named functions, to/from JSON format.

Changes

Files Modified:

  • src/broadbean/blueprint.py (+131 lines) - Core serialization/deserialization logic
  • tests/test_arb_func_serialization.py (+263 lines) - New comprehensive test suite
  • tests/test_blueprint.py (+1 line) - Updated existing test

Key Features

  1. Lambda Function Serialization: Lambda functions used with PulseAtoms.arb_func can now be serialized to JSON by storing their source code, extracted using inspect.getsource() with regex parsing.

  2. Named Function Serialization: Regular named functions are serialized with both their name and full source code, allowing reconstruction even in different Python sessions.

  3. Graceful Fallbacks: When functions cannot be reconstructed (e.g., missing dependencies or invalid source), the system falls back to a zero function with appropriate warnings.

  4. Sample Rate Persistence: The SR (sample rate) property is now included in the blueprint description and restored during deserialization.

  5. Backward Compatibility: Legacy JSON formats without the new func_type field are still supported.

Usage Example

import broadbean as bb
import numpy as np

# Create a blueprint with a lambda function using arb_func
bp = bb.BluePrint()

# Add a segment using arb_func with a lambda function
my_lambda = lambda t, **kwargs: np.sin(2 * np.pi * kwargs.get('freq', 1) * t)
bp.insertSegment(
    0, 
    bb.PulseAtoms.arb_func, 
    args=(my_lambda, {'freq': 10}),  # (function, kwargs_dict)
    name='sine_wave',
    dur=1e-6
)
bp.setSR(1e9)

# Serialize to JSON file
bp.write_to_json('my_blueprint.json')

# Later, reconstruct the blueprint from JSON
bp_restored = bb.BluePrint.init_from_json('my_blueprint.json')

# The lambda function is reconstructed and works as expected
print(bp_restored.description)

Example with a named function:

import broadbean as bb
import numpy as np

# Define a custom waveform function
def gaussian_pulse(t, **kwargs):
    amplitude = kwargs.get('amplitude', 1.0)
    sigma = kwargs.get('sigma', 1e-7)
    center = kwargs.get('center', 5e-7)
    return amplitude * np.exp(-((t - center) ** 2) / (2 * sigma ** 2))

# Create blueprint
bp = bb.BluePrint()
bp.insertSegment(
    0,
    bb.PulseAtoms.arb_func,
    args=(gaussian_pulse, {'amplitude': 0.5, 'sigma': 2e-7, 'center': 5e-7}),
    name='gaussian',
    dur=1e-6
)
bp.setSR(1e9)

# Save and restore
bp.write_to_json('gaussian_blueprint.json')
bp_restored = bb.BluePrint.init_from_json('gaussian_blueprint.json')

Technical Details

Serialization (description property):

  • Detects arb_func segments and extracts function information
  • For lambdas: extracts source code using regex to isolate the lambda expression
  • For named functions: stores both function name and complete source code
  • Stores associated kwargs dictionary

Deserialization (blueprint_from_description):

  • Reconstructs lambda functions using eval()
  • Reconstructs named functions by executing source code in isolated namespace
  • Falls back to global namespace lookup or zero function if reconstruction fails

Testing

Added comprehensive test suite (test_arb_func_serialization.py) covering:

  • Lambda function serialization round-trip
  • Named function serialization round-trip
  • Complex expressions with numpy operations
  • Kwargs preservation
  • JSON file write/read operations
  • Error handling and fallbacks
  • Integration with existing blueprint functionality

Breaking Changes

None - existing functionality and JSON format remains compatible.

@codecov
Copy link

codecov bot commented Feb 3, 2026

Codecov Report

❌ Patch coverage is 42.42424% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.16%. Comparing base (ee22118) to head (93d64c2).

Files with missing lines Patch % Lines
src/broadbean/blueprint.py 42.42% 38 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1695      +/-   ##
==========================================
- Coverage   72.12%   71.16%   -0.97%     
==========================================
  Files           9        9              
  Lines        1539     1602      +63     
==========================================
+ Hits         1110     1140      +30     
- Misses        429      462      +33     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are there two duplicated(?) plots instead of one in this example? or is it github mis-rendering?

Comment on lines +85 to 95
# for ii, name in enumerate(namelist):
# if isinstance(funlist[ii], str):
# namelist[ii] = funlist[ii]
# elif name == "":
# namelist[ii] = funlist[ii].__name__
for ii, name in enumerate(namelist):
if isinstance(funlist[ii], str):
namelist[ii] = funlist[ii]
if name == "":
namelist[ii] = funlist[ii]
elif name == "":
namelist[ii] = funlist[ii].__name__
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, what happens now in the else of if isinstance(funlist[ii], str): if name == "":? I am a bit worried about the test coverage of this portion of the code being too low to ensure this change is not breaking anything - what do you think?

why keeping the commented out code part?


self._namelist = namelist
namelist = self._make_names_unique(namelist)
self._namelist = namelist
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks like a good catch! thank you!

func_source = "lambda t, **kwargs: 0"
except (OSError, TypeError):
# Fallback: create a generic lambda string
func_source = "lambda t, **kwargs: 0" # Default fallback
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't it rather be None, like in case the source code of named function is not possible to retireve? This would make the behavior consistent while having a default fallback can become confusing

if func_name in local_ns:
func_obj = local_ns[func_name]
except Exception as e:
print(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

next to printing please use logging.warning so that this information can also show as a proper warning, and be included in logs (if those are used)

# Regular function - store name and try to get source
func_name = func_obj.__name__
try:
func_source = inspect.getsource(func_obj)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't it extremely fragile? What is a function is using functions from math module or numpy, would those imports be also part of the source?

also, i can't imagine that noone tried to solve this problem before - can't more functions/methods from the python standard library or perhaps even an external package be used to support this rather than implementing a custom function code serialization here?

func_obj, kwargs_dict = self._argslist[sn]

# Serialize the function
if hasattr(func_obj, "__name__") and func_obj.__name__ != "<lambda>":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you move the logic of extracting code a function into a separate function in order not to mix it with the logic of creating blueprint description?

try:
# Execute the function source in a local namespace
local_ns = {}
exec(func_source, globals(), local_ns)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is dangerous to make globals available, both from security perspective but also from the perspective of writing code that would prepare some globals to exist before deserializing certain blueprints and their arbitrary functions. Would it be possible to limit support to self-sufficient functions? at least to start with

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants